From b6e8b8efb2d4779b5a302a794f04f02da2704b80 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Fri, 15 May 2026 14:24:04 +0200 Subject: [PATCH 01/11] update docstrings for ty and ruff --- hololinked/core/interfaces/configuration.py | 4 +- hololinked/core/interfaces/metadata.py | 72 +++++++ hololinked/td/__init__.py | 2 + hololinked/td/base.py | 38 +++- hololinked/td/data_schema.py | 89 +++++---- hololinked/td/forms.py | 15 +- hololinked/td/interaction_affordance.py | 90 ++++++--- hololinked/td/metadata.py | 10 +- hololinked/td/pydantic_extensions.py | 197 ++++++++++++++++++-- hololinked/td/security_definitions.py | 24 ++- hololinked/td/tm.py | 33 +++- hololinked/td/utils.py | 4 +- 12 files changed, 465 insertions(+), 113 deletions(-) create mode 100644 hololinked/core/interfaces/metadata.py 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..c274b8a5 --- /dev/null +++ b/hololinked/core/interfaces/metadata.py @@ -0,0 +1,72 @@ +"""Metadata Management Abstract Base Class.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + + +if TYPE_CHECKING: + from hololinked.core.thing import Thing + + +class Metadata: + """A base class to generate device or Thing metadata and conversely produce a Thing instance from the metadata.""" + + def __init__( + self, + thing: Thing | None = None, + ignore_errors: bool = False, + skip_names: Optional[list[str]] = [], + ) -> None: + """ + Initialize the Metadata handler. + + 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. + """ + self.thing = thing + self.ignore_errors = ignore_errors + self.skip_names = skip_names or [] + + def generate(self) -> Metadata: + """Populate the metadata.""" + raise NotImplementedError("implement generate() in subclass") + + def produce(self) -> Thing: + """Produce a Thing instance from the metadata.""" + raise NotImplementedError("This will be implemented in a future release for an API first approach") + + skip_properties: list[str] + """list of default property names to skip when generating the metadata.""" + + skip_actions: list[str] + """list of default action names to skip when generating the metadata.""" + + skip_events: list[str] + """list of default event names to skip when generating the metadata.""" + + def add_interactions(self) -> None: + """Add interaction (affordances) to the metadata.""" + ... + + def model_dump(self, **kwargs) -> dict[str, Any]: + """Return the JSON representation of the schema.""" + ... + + def json(self, **kwargs) -> dict[str, Any]: + """ + Return the JSON string representation of the schema. + + Returns + ------- + dict[str, Any] + The JSON string representation of the schema. + """ + return self.model_dump(**kwargs) diff --git a/hololinked/td/__init__.py b/hololinked/td/__init__.py index 1c7ef12a..50f1dc88 100644 --- a/hololinked/td/__init__.py +++ b/hololinked/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/td/base.py index 580206e6..63194a51 100644 --- a/hololinked/td/base.py +++ b/hololinked/td/base.py @@ -1,3 +1,5 @@ +"""Base Schema class for all WoT schema components.""" + import inspect from typing import Any, ClassVar @@ -5,16 +7,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. """ skip_keys: ClassVar = [] # override this to skip some dataclass attributes in the schema def model_dump(self, **kwargs) -> dict[str, Any]: - """Return the JSON representation of the schema""" + """ + Return the JSON representation of the schema. + + Returns + ------- + dict[str, Any] + JSON representation + """ # we need to override this to work with our JSON serializer kwargs["mode"] = "json" kwargs["by_alias"] = True @@ -30,13 +40,29 @@ def model_dump(self, **kwargs) -> dict[str, Any]: ] return super().model_dump(**kwargs) - def json(self) -> dict[str, Any]: - """same as model_dump""" + def json(self) -> dict[str, Any]: # noqa + """ + Same as model_dump. + + Overrides pydantic's base class `json()` method. + + Returns + ------- + dict[str, Any] + JSON representation + """ # noqa: D401 return self.model_dump() @classmethod - def format_doc(cls, doc: str): - """strip tabs, newlines, whitespaces etc. to format the docstring nicely""" + def format_doc(cls, doc: str) -> str: + """ + Strip tabs, newlines, whitespaces etc. to format the docstring nicely. + + Returns + ------- + str + Formatted docstring + """ doc = inspect.cleandoc(doc) # Remove everything after "Parameters\n-----" if present (when using numpydoc) marker = "Parameters\n-----" diff --git a/hololinked/td/data_schema.py b/hololinked/td/data_schema.py index b532ec4b..bfc87e64 100644 --- a/hololinked/td/data_schema.py +++ b/hololinked/td/data_schema.py @@ -1,10 +1,15 @@ +"""Implementations of Data Schema.""" + from typing import Any, ClassVar, Optional from pydantic import BaseModel, ConfigDict, Field, RootModel from hololinked import JSONSchema +from hololinked.constants import JSON, JSONSerializable +from hololinked.td.base import WoTSchema +from hololinked.td.utils import get_summary +from hololinked.utils import issubklass -from ..constants import JSON, JSONSerializable from ..core import Property from ..core.properties import ( Boolean, @@ -23,15 +28,11 @@ TypedKeyMappingsDict, TypedList, ) -from ..utils import issubklass -from .base import Schema -from .utils import get_summary -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) @@ -58,7 +59,7 @@ def __init__(self): super().__init__() def ds_build_fields_from_property(self, property: Property) -> None: - """Populates schema information from property descriptor object""" + """Populates schema information from property descriptor object.""" self.title = get_summary(property.doc) if property.constant: self.const = property.constant @@ -67,7 +68,7 @@ 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() @@ -88,8 +89,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 +149,21 @@ def ds_build_from_property(self, property: Property) -> None: setattr(self, field_name, field_value) def _move_own_type_to_oneOf(self): - """Move a type to oneOf""" + """Move a type to oneOf, usually when allow None is True.""" pass class BooleanSchema(DataSchema): """ - boolean schema - https://www.w3.org/TR/wot-thing-description11/#booleanschema - used by Boolean descriptor + Boolean Schema - https://www.w3.org/TR/wot-thing-description11/#booleanschema. + + Used by Boolean descriptor. """ def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 self.type = "boolean" super().ds_build_fields_from_property(property) @@ -165,8 +178,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 +190,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 +212,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 +226,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,8 +262,9 @@ def _move_own_type_to_oneOf(self): class ArraySchema(DataSchema): """ - array schema - https://www.w3.org/TR/wot-thing-description11/#arrayschema - Used by list, Tuple, TypedList and TupleSelector + Array Schema - https://www.w3.org/TR/wot-thing-description11/#arrayschema. + + Used by List, Tuple, TypedList and TupleSelector. """ items: Optional[DataSchema | list[DataSchema] | JSON | JSONSerializable] = None @@ -260,8 +274,7 @@ class ArraySchema(DataSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 self.type = "array" self.items = [] if isinstance(property, (List, Tuple, TypedList)) and property.item_type is not None: @@ -305,8 +318,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,8 +329,7 @@ class ObjectSchema(DataSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 super().ds_build_fields_from_property(property) properties = None required = None @@ -342,7 +355,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 +370,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 +432,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,8 +442,7 @@ class EnumSchema(SelectorSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 if not isinstance(property, Selector): raise TypeError(f"EnumSchema compatible property is only Selector, not {property.__class__}") self.enum = list(property.objects) diff --git a/hololinked/td/forms.py b/hololinked/td/forms.py index b28ef0a8..cbc23f48 100644 --- a/hololinked/td/forms.py +++ b/hololinked/td/forms.py @@ -1,3 +1,5 @@ +"""Implementation of Forms.""" + from typing import Any, Optional from pydantic import Field @@ -8,8 +10,9 @@ class ExpectedResponse(Schema): """ - Form property. - schema - https://www.w3.org/TR/wot-thing-description11/#expectedresponse + Form field for the expected response of an interaction. + + https://www.w3.org/TR/wot-thing-description11/#expectedresponse """ contentType: str @@ -21,7 +24,8 @@ def __init__(self): class AdditionalExpectedResponse(Schema): """ Form field for additional responses which are different from the usual response. - schema - https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse + + https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse """ success: bool = Field(default=False) @@ -35,7 +39,8 @@ def __init__(self): class Form(Schema): """ Form hypermedia. - schema - https://www.w3.org/TR/wot-thing-description11/#form + + https://www.w3.org/TR/wot-thing-description11/#form """ href: str = None @@ -78,5 +83,5 @@ def from_TD(cls, form_json: dict[str, Any]) -> "Form": setattr(form, field, form_json[field]) return form - def __str__(self) -> str: + def __str__(self) -> str: # noqa: D105 return f"Form(href={self.href}, op={self.op}, htv_methodName={self.htv_methodName}, contentType={self.contentType})" diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py index b5b12c04..a64e5083 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -1,3 +1,5 @@ +"""Implementation of Interaction Affordances.""" + import copy from enum import Enum @@ -20,7 +22,7 @@ class InteractionAffordance(Schema): """ - Implements schema information common to all interaction affordances. + Implements schema fields common to all interaction affordances, property, action or event. [Specification Definitions](https://www.w3.org/TR/wot-thing-description11/#interactionaffordance)
[UML Diagram](https://docs.hololinked.dev/UML/PDF/InteractionAffordance.pdf)
@@ -46,14 +48,15 @@ def __init__(self): @property def what(self) -> Enum: - """Whether it is a property, action or event""" + """Whether it is a property, action or event.""" raise NotImplementedError("Unknown interaction affordance - implement in subclass of InteractionAffordance") @property def owner(self) -> Thing: """ Owning `Thing` instance or `Thing` class of the interaction affordance. - Depends on how this object was created, whether using an instance or a class. + + Depending on how this object was created, returns either an instance or a class. """ return self._owner @@ -75,12 +78,11 @@ def owner(self, value): @property def objekt(self) -> Property | Action | Event: - """Object instance of the interaction affordance - `Property`, `Action` or `Event`""" + """Object instance of the interaction affordance, instance of `Property`, `Action` or `Event`.""" return self._objekt @objekt.setter def objekt(self, value: Property | Action | Event) -> None: - """Set the object instance of the interaction affordance - `Property`, `Action` or `Event`""" if self._objekt is not None: raise ValueError( f"object is already set for this {self.what.name.lower()} affordance, " @@ -102,26 +104,26 @@ def objekt(self, value: Property | Action | Event) -> None: @property def name(self) -> str: - """Name of the interaction affordance used as key in the TD""" + """Name of the interaction affordance used as key in the TD.""" return self._name @property def thing_id(self) -> str: - """ID of the `Thing` instance owning the interaction affordance, if available, otherwise None""" + """ID of the `Thing` instance owning the interaction affordance, if available, otherwise None.""" return self._thing_id @property def thing_cls(self) -> ThingMeta: - """`Thing` class owning the interaction affordance""" + """`Thing` class owning the interaction affordance.""" return self._thing_cls def build(self) -> None: - """Populate the fields of the schema for the specific interaction affordance""" + """Populate the fields of the schema for the specific interaction affordance.""" raise NotImplementedError("build must be implemented in subclass of InteractionAffordance") def retrieve_form(self, op: str, default: Any = None) -> Form: """ - Retrieve form for a certain operation, return default if not found + Retrieve form for a certain operation, return default if not found. Parameters ---------- @@ -145,7 +147,7 @@ def retrieve_form(self, op: str, default: Any = None) -> Form: def pop_form(self, op: str, default: Any = None) -> Form: """ - Retrieve and remove form for a certain operation, return default if not found + Retrieve and remove form for a certain operation, return default if not found. Parameters ---------- @@ -174,11 +176,12 @@ def generate( owner: Thing, ) -> "PropertyAffordance | ActionAffordance | EventAffordance": """ - Build the schema for the specific interaction affordance as an instance of this class. + Instantitate and build the schema for the specific interaction affordance. + Use the `json()` method to get the JSON representation of the schema. - Note that this method is different from build() method as its supposed to be used as a classmethod - to create an instance. Although, it internally calls build(), and some additional steps are included. + Note that this method is different from `build()` method as its supposed to be used as a classmethod + to create an instance. Although, it internally calls `build()`, and some additional steps are included. Parameters ---------- @@ -190,6 +193,7 @@ def generate( Returns ------- "PropertyAffordance | ActionAffordance | EventAffordance" + Instance of this class with the schema fields populated. """ raise NotImplementedError("generate_schema must be implemented in subclass of InteractionAffordance") @@ -198,6 +202,9 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance """ Populate the schema from the TD and return it as an instance of this class. + You need to supply both the TD and the name of the affordance, because the affordance definition in the TD + does not include its name and determine its type. + Parameters ---------- name: str @@ -208,6 +215,12 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance Returns ------- "PropertyAffordance | ActionAffordance | EventAffordance" + Instance of this class with the schema fields populated from the TD. + + Raises + ------ + ValueError + If the affordance type cannot be determined from the TD. """ if cls == PropertyAffordance: affordance_name = "properties" @@ -235,7 +248,23 @@ def register_descriptor( descriptor: Property | Action | Event, schema_generator: "InteractionAffordance", ) -> None: - """Register a custom schema generator for a descriptor""" + """ + Register a custom schema generator for a descriptor. + + Parameters + ---------- + descriptor: Property | Action | Event + The descriptor class + schema_generator: `InteractionAffordance` + `InteractionAffordance` subclass that implements the custom schema generation logic for the descriptor. + Either override the `generate()` method or the `build()` method. + + Raises + ------ + TypeError + If the descriptor is not an instance of `Property`, `Action` or `Event`, or if the schema generator is not an + instance of `InteractionAffordance`. + """ if not isinstance(descriptor, (Property, Action, Event)): raise TypeError( "custom schema generator can also be registered for Property." + f" Given type {type(descriptor)}" @@ -248,12 +277,13 @@ def register_descriptor( InteractionAffordance._custom_schema_generators[descriptor] = schema_generator def build_non_compliant_metadata(self) -> None: - """If by chance, there is additional non standard metadata to be added, they can be added here""" + """If there is additional non standard metadata to be added, they can be added here.""" pass def override_defaults(self, **kwargs): """ Override default values with provided keyword arguments, especially thing_id, owner name, object name etc. + Any logic to trigger side effects while setting those values should be handled here. """ for key, value in kwargs.items(): @@ -268,17 +298,17 @@ def override_defaults(self, **kwargs): elif hasattr(self, key) or key in self.model_fields: setattr(self, key, value) - def __hash__(self): + def __hash__(self): # noqa: D105 return hash( self.thing_id if self.thing_id else "" + self.thing_cls.__name__ if self.thing_cls else "" + self.name ) - def __str__(self): + def __str__(self): # noqa: D105 if self.thing_cls: return f"{self.__class__.__name__}({self.thing_cls.__name__}({self.thing_id}).{self.name})" return f"{self.__class__.__name__}({self.name} of {self.thing_id})" - def __eq__(self, value): + def __eq__(self, value): # noqa: D105 if not isinstance(value, self.__class__): return False if self.thing_id is None or value.thing_id is None: @@ -293,7 +323,7 @@ def __eq__(self, value): return False return self.thing_id == value.thing_id and self.name == value.name - def __deepcopy__(self, memo): + def __deepcopy__(self, memo): # noqa: D105 if self.__class__ == PropertyAffordance: result = PropertyAffordance() elif self.__class__ == ActionAffordance: @@ -306,7 +336,7 @@ def __deepcopy__(self, memo): setattr(result, k, copy.deepcopy(v, memo)) return result - def __getstate__(self): + def __getstate__(self): # noqa: D105 state = self.__dict__.copy() # Remove possibly unpicklable entries if "_owner" in state: @@ -333,17 +363,17 @@ def __init__(self): super().__init__() @property - def what(self) -> Enum: + def what(self) -> Enum: # noqa: D102 return ResourceTypes.PROPERTY - def build(self) -> None: + def build(self) -> None: # noqa: D102 property = self.objekt self.ds_build_from_property(property) if property.observable: self.observable = property.observable @classmethod - def generate(cls, property, owner=None): + def generate(cls, property, owner=None): # noqa: D102 if not isinstance(property, Property): raise TypeError(f"property must be instance of Property, given type {type(property)}") affordance = PropertyAffordance() @@ -373,10 +403,10 @@ def __init__(self): super().__init__() @property - def what(self): + def what(self): # noqa: D102 return ResourceTypes.ACTION - def build(self) -> None: + def build(self) -> None: # noqa: D102 action = self.objekt # type: Action if action.obj.__doc__: title = get_summary(action.obj.__doc__) @@ -419,7 +449,7 @@ def build(self) -> None: self.safe = action.execution_info.safe @classmethod - def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": + def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": # noqa: D102 if not isinstance(action, Action): raise TypeError(f"action must be instance of Action, given type {type(action)}") affordance = ActionAffordance() @@ -446,10 +476,10 @@ def __init__(self): super().__init__() @property - def what(self): + def what(self): # noqa: D102 return ResourceTypes.EVENT - def build(self) -> None: + def build(self) -> None: # noqa: D102 event = self.objekt # type: Event if event.__doc__: title = get_summary(event.doc) @@ -468,7 +498,7 @@ def build(self) -> None: raise ValueError(f"unknown schema definition for event data, given type: {type(event.schema)}") @classmethod - def generate(cls, event: Event, owner, **kwargs) -> "EventAffordance": + def generate(cls, event: Event, owner, **kwargs) -> "EventAffordance": # noqa: D102 if not isinstance(event, Event): raise TypeError(f"event must be instance of Event, given type {type(event)}") affordance = EventAffordance() diff --git a/hololinked/td/metadata.py b/hololinked/td/metadata.py index 4870a755..cb2ee768 100644 --- a/hololinked/td/metadata.py +++ b/hololinked/td/metadata.py @@ -1,3 +1,5 @@ +"""Include a general metadata like links, version info, etc. here.""" + from typing import Optional from pydantic import Field @@ -7,8 +9,9 @@ class Link(Schema): """ - Represents a link in the link section of the TD - schema - https://www.w3.org/TR/wot-thing-description11/#link + Impelements the Link schema for linking to other resources. + + https://www.w3.org/TR/wot-thing-description11/#link """ href: str @@ -20,7 +23,8 @@ class Link(Schema): class VersionInfo(Schema): """ Represents version info. - schema - https://www.w3.org/TR/wot-thing-description11/#versioninfo + + https://www.w3.org/TR/wot-thing-description11/#versioninfo """ instance: str diff --git a/hololinked/td/pydantic_extensions.py b/hololinked/td/pydantic_extensions.py index 66f34481..301b5ac3 100644 --- a/hololinked/td/pydantic_extensions.py +++ b/hololinked/td/pydantic_extensions.py @@ -1,3 +1,32 @@ +""" +pydantic specific utility functions for the TD module. + +This module is largely copied from LabThings fast API. +Copyright belongs to LabThings, Richard Bowman and developers, licensed under MIT License. + +MIT License + +Copyright (c) 2024 Richard William Bowman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + from __future__ import annotations from typing import Any, Dict, List, Mapping, Optional, Sequence, Union @@ -21,22 +50,51 @@ def is_a_reference(d: 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: """ - 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( @@ -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,7 +217,19 @@ 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() @@ -121,7 +240,21 @@ 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?") @@ -133,7 +266,7 @@ def jsonschema_to_dataschema( recursion_limit: int = 99, ) -> JSONSchemaType: """ - 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) @@ -186,7 +335,7 @@ def jsonschema_to_dataschema( def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: """ - Convert a Python type to a Thing Description DataSchema + Convert a Python type to a Thing Description DataSchema. This makes use of pydantic's `schema_of` function to create a json schema, then applies some fixes to make a DataSchema @@ -197,6 +346,19 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: and will override the fields generated from the type that is passed in. Typically you'll want to use this for the `title` field. + + Parameters + ---------- + t : type or BaseModel + The Python type or pydantic model to convert. + **kwargs : Any + Additional fields to merge into the resulting DataSchema, overriding any + auto-generated values. + + Returns + ------- + dict + The Thing Description DataSchema representation of the given type. """ if isinstance(t, BaseModel): json_schema = t.model_json_schema(schema_generator=GenerateJsonSchemaWithoutDefaultTitles) @@ -220,10 +382,23 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: class GenerateJsonSchemaWithoutDefaultTitles(GenerateJsonSchema): - """Drops autogenerated titles from JSON Schema""" + """Drops autogenerated titles from JSON Schema.""" # https://stackoverflow.com/questions/78679812/pydantic-v2-to-json-schema-translation-how-to-suppress-autogeneration-of-title def field_title_should_be_set(self, schema: CoreSchemaOrField) -> bool: + """Return False for core schemas to suppress autogenerated field titles. + + Parameters + ---------- + schema : CoreSchemaOrField + The pydantic core schema or field schema being evaluated. + + Returns + ------- + bool + False if the schema is a core schema and the parent would set a title, + otherwise the parent class result. + """ return_value = super().field_title_should_be_set(schema) if return_value and is_core_schema(schema): return False diff --git a/hololinked/td/security_definitions.py b/hololinked/td/security_definitions.py index 68ab1018..3411ef3c 100644 --- a/hololinked/td/security_definitions.py +++ b/hololinked/td/security_definitions.py @@ -1,3 +1,5 @@ +"""Implements security scheme definitions for the TD.""" + from typing import Optional from pydantic import Field @@ -7,8 +9,9 @@ class SecurityScheme(Schema): """ - Represents a security scheme. - schema - https://www.w3.org/TR/wot-thing-description11/#sec-security-vocabulary-definition + Subclass from here to implement Security Scheme metadata. + + https://www.w3.org/TR/wot-thing-description11/#sec-security-vocabulary-definition """ scheme: str = None @@ -20,47 +23,48 @@ def __init__(self): super().__init__() def build(self): + """Populate the security scheme metadata.""" raise NotImplementedError("Please implement specific security scheme builders") class NoSecurityScheme(SecurityScheme): - """No Security Scheme""" + """No Security Scheme.""" - def build(self): + def build(self): # noqa: D102 self.scheme = "nosec" self.description = "currently no security scheme supported" class BasicSecurityScheme(SecurityScheme): - """Basic Security Scheme, username and password""" + """Basic Security Scheme - username and password.""" in_: str = Field(default="header", alias="in") - def build(self): + def build(self): # noqa: D102 self.scheme = "basic" self.description = "HTTP Basic Authentication" self.in_ = "header" class APIKeySecurityScheme(SecurityScheme): - """API Key Security Scheme""" + """API Key Security Scheme.""" in_: str = Field(default="header", alias="in") - def build(self): + def build(self): # noqa: D102 self.scheme = "apikey" self.description = "API Key Authentication" self.in_ = "header" class OIDCSecurityScheme(SecurityScheme): - """OIDC Security Scheme""" + """OIDC Security Scheme.""" scheme: str = "oauth2" token: str = "" scopes: list[str] = Field(default_factory=list) - def build(self, token_url: str, scopes: list[str] | None = ["openid"]): + def build(self, token_url: str, scopes: list[str] | None = ["openid"]): # noqa: D102 self.description = "OpenID Connect Authentication" self.token = token_url if scopes is not None: diff --git a/hololinked/td/tm.py b/hololinked/td/tm.py index 42a5de7b..61ff148c 100644 --- a/hololinked/td/tm.py +++ b/hololinked/td/tm.py @@ -1,3 +1,9 @@ +""" +Implemetation of Thing Model. + +Thing Descriptions are always generated by the specific protocols. +""" + from typing import Any, Optional from pydantic import ConfigDict, Field @@ -16,10 +22,10 @@ class ThingModel(Schema): """ - Thing Model as per W3C WoT Thing Description v1.1 + Thing Model as per W3C WoT Thing Description v1.1. - [Specification](https://www.w3.org/TR/wot-thing-description11/)
- [UML Diagram](https://docs.hololinked.dev/UML/PDF/ThingModel.pdf)
+ - [Specification](https://www.w3.org/TR/wot-thing-description11/) + - [UML Diagram](https://docs.hololinked.dev/UML/PDF/ThingModel.pdf) """ context: list[str | dict[str, str]] = Field(["https://www.w3.org/2022/wot/td/v1.1"], alias="@context") @@ -52,7 +58,13 @@ def __init__( self.skip_names = skip_names or [] def generate(self) -> "ThingModel": - """populate the thing model""" + """ + Populate the thing model. + + Returns + ------- + ThingModel + """ self.id = self.instance.id self.title = self.instance.__class__.__name__ self.context = ["https://www.w3.org/2022/wot/td/v1.1"] @@ -66,7 +78,7 @@ def generate(self) -> "ThingModel": return self def produce(self) -> Thing: - """produce a Thing instance from the Thing Model, not implemented yet""" + """Produce a Thing instance from the Thing Model, not implemented yet.""" raise NotImplementedError("This will be implemented in a future release for an API first approach") # not the best code and logic, but works for now @@ -87,7 +99,7 @@ def produce(self) -> Thing: """list of event names to skip when generating the TD""" def add_interaction_affordances(self) -> None: - """add interaction affordances to thing model""" + """Add interaction affordances to thing model.""" for affordance, items, affordance_cls, skip_list in [ ["properties", self.instance.properties.remote_objects.items(), PropertyAffordance, self.skip_properties], ["actions", self.instance.actions.descriptors.items(), ActionAffordance, self.skip_actions], @@ -114,7 +126,14 @@ def add_interaction_affordances(self) -> None: self.instance.logger.error(f"Error while generating schema for {name} - {ex}") def model_dump(self, **kwargs) -> dict[str, Any]: - """Return the JSON representation of the schema""" + """ + Return the JSON representation of the schema. + + Returns + ------- + dict[str, Any] + JSON representation + """ def dump_value(value): nonlocal kwargs diff --git a/hololinked/td/utils.py b/hololinked/td/utils.py index 6d464ab0..e2e3cf70 100644 --- a/hololinked/td/utils.py +++ b/hololinked/td/utils.py @@ -1,9 +1,11 @@ +"""utility functions for the TD module.""" + from typing import Optional def get_summary(docs: str) -> Optional[str]: """ - Return the first line of the dosctring of an object + Return the first line of the docstring of an object. Parameters ---------- From af93eddcdd574629910251831b301876c2770bcc Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Sun, 17 May 2026 08:09:32 +0200 Subject: [PATCH 02/11] update imports and do unit tests --- .../client/zmq/consumed_interactions.py | 18 +- hololinked/core/interfaces/__init__.py | 9 + hololinked/core/interfaces/metadata.py | 266 +++++++++++++++++- hololinked/core/thing.py | 45 ++- hololinked/td/base.py | 4 +- hololinked/td/data_schema.py | 13 +- hololinked/td/forms.py | 12 +- hololinked/td/interaction_affordance.py | 174 +++--------- hololinked/td/metadata.py | 8 +- hololinked/td/security_definitions.py | 6 +- hololinked/td/tm.py | 55 ++-- hololinked/td/utils.py | 2 + 12 files changed, 401 insertions(+), 211 deletions(-) diff --git a/hololinked/client/zmq/consumed_interactions.py b/hololinked/client/zmq/consumed_interactions.py index 8470d6f8..7c05046f 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.td import ActionAffordance, EventAffordance, PropertyAffordance +from hololinked.td.forms import Form __error_message_types__ = [TIMEOUT, ERROR, INVALID_MESSAGE] 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/metadata.py b/hololinked/core/interfaces/metadata.py index c274b8a5..cbbcd9d2 100644 --- a/hololinked/core/interfaces/metadata.py +++ b/hololinked/core/interfaces/metadata.py @@ -2,16 +2,26 @@ from __future__ import annotations +from enum import Enum from typing import TYPE_CHECKING, Any, Optional +from pydantic import BaseModel, ConfigDict + +from hololinked.constants import JSON, ResourceTypes + if TYPE_CHECKING: - from hololinked.core.thing import Thing + 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: +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, @@ -31,6 +41,7 @@ def __init__( 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 [] @@ -56,17 +67,256 @@ def add_interactions(self) -> None: """Add interaction (affordances) to the metadata.""" ... - def model_dump(self, **kwargs) -> dict[str, Any]: - """Return the JSON representation of the schema.""" - ... - def json(self, **kwargs) -> dict[str, Any]: """ - Return the JSON string representation of the schema. + Return the JSON string representation. Returns ------- dict[str, Any] - The JSON string representation of the schema. + 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 called as interaction metadata. This base class defines metadata methods common to all of properties, + actions or events, and which could be common to different metadata standards. + """ + + _custom_schema_generators: 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. + """ + return self._owner + + @property + def owner_cls(self) -> ThingMeta: + """Return the owning `Thing` class of the interaction.""" + return self._thing_cls + + @property + def objekt(self) -> Property | Action | Event: + """Object instance of the interaction, instance of `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.""" + return self._name + + @property + def thing_id(self) -> str: + """ID of the `Thing` instance owning the interaction, if available, otherwise None.""" + return self._thing_id + + @property + def thing_cls(self) -> ThingMeta: + """`Thing` class owning the interaction.""" + 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 generate( + cls, + interaction: Property | Action | Event, + owner: Thing | ThingMeta = None, + ) -> PropertyMetadata | ActionMetadata | EventMetadata: + """ + 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("generate() must be implemented in subclass of InteractionMetadata") + + @classmethod + def from_metadata(cls, name: str, metadata: JSON) -> PropertyMetadata | ActionMetadata | EventMetadata: + """ + 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 + ------- + PropertyMetadata | ActionMetadata | EventMetadata + Instance of this class. + + Raises + ------ + ValueError + If the interaction type cannot be determined from the metadata. + """ + raise NotImplementedError + + @classmethod + def register_descriptor( + cls, + descriptor: Property | Action | Event, + schema_generator: InteractionMetadata, + ) -> None: + """ + Register a custom schema generator for a descriptor. + + Parameters + ---------- + descriptor: Property | Action | Event + The descriptor class + schema_generator: InteractionMetadata + `InteractionMetadata` subclass that implements the custom schema generation logic for the descriptor. + Either override the `generate()` method or the `build()` method. + + Raises + ------ + TypeError + If the descriptor is not an instance of `Property`, `Action` or `Event`, or if the schema generator is not an + instance of `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. + """ + 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): + """Implements property affordance schema from `Property` descriptor object.""" + + @property + def what(self) -> Enum: # noqa: D102 + return ResourceTypes.PROPERTY + + @classmethod + def generate(cls, property: Property, owner: Thing | ThingMeta = None) -> PropertyMetadata: # noqa: D102 + raise NotImplementedError + + +class ActionMetadata(InteractionMetadata): + """Implements action affordance schema from `Action` descriptor object.""" + + @property + def what(self) -> Enum: # noqa: D102 + return ResourceTypes.ACTION + + @classmethod + def generate(cls, action: Action, owner: Thing | ThingMeta = None) -> ActionMetadata: # noqa: D102 + raise NotImplementedError + + +class EventMetadata(InteractionMetadata): + """Implements event affordance schema from `Event` descriptor object.""" + + @property + def what(self) -> Enum: # noqa: D102 + return ResourceTypes.EVENT + + @classmethod + def generate(cls, event: Event, owner: Thing | ThingMeta = None) -> EventMetadata: # noqa: D102 + raise NotImplementedError diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index a0345903..de593c32 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -1,3 +1,5 @@ +"""Concrete Implementation of a `Thing` that represents a physical or virtual object.""" + import inspect import logging import ssl @@ -6,7 +8,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,7 +21,9 @@ 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 + 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. @@ -94,6 +98,8 @@ def __init__( **kwargs: dict[str, Any], ) -> None: """ + Initialize a `Thing`. + Parameters ---------- id: str @@ -201,9 +207,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] = []) -> 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. @@ -224,9 +231,9 @@ def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = [ # 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 + from hololinked.td.tm import ThingModel - return ThingModel(instance=self, ignore_errors=ignore_errors, skip_names=skip_names).generate() + return ThingModel(thing=self, ignore_errors=ignore_errors, skip_names=skip_names).generate() thing_model = property(get_thing_model, doc=get_thing_model.__doc__) @@ -238,8 +245,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 +301,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 +350,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 +371,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 +396,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 +414,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/td/base.py b/hololinked/td/base.py index 63194a51..e69d4393 100644 --- a/hololinked/td/base.py +++ b/hololinked/td/base.py @@ -1,5 +1,7 @@ """Base Schema class for all WoT schema components.""" +from __future__ import annotations + import inspect from typing import Any, ClassVar @@ -11,7 +13,7 @@ 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 diff --git a/hololinked/td/data_schema.py b/hololinked/td/data_schema.py index bfc87e64..1dc12c78 100644 --- a/hololinked/td/data_schema.py +++ b/hololinked/td/data_schema.py @@ -1,17 +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 hololinked.constants import JSON, JSONSerializable -from hololinked.td.base import WoTSchema -from hololinked.td.utils import get_summary -from hololinked.utils import issubklass - -from ..core import Property -from ..core.properties import ( +from hololinked.core import Property +from hololinked.core.properties import ( Boolean, ClassSelector, Filename, @@ -28,6 +26,9 @@ TypedKeyMappingsDict, TypedList, ) +from hololinked.td.base import WoTSchema +from hololinked.td.utils import get_summary +from hololinked.utils import issubklass class DataSchema(WoTSchema): diff --git a/hololinked/td/forms.py b/hololinked/td/forms.py index cbc23f48..02713f6b 100644 --- a/hololinked/td/forms.py +++ b/hololinked/td/forms.py @@ -1,14 +1,16 @@ """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.constants import JSON +from hololinked.td.base import WoTSchema -class ExpectedResponse(Schema): +class ExpectedResponse(WoTSchema): """ Form field for the expected response of an interaction. @@ -21,7 +23,7 @@ def __init__(self): super().__init__() -class AdditionalExpectedResponse(Schema): +class AdditionalExpectedResponse(WoTSchema): """ Form field for additional responses which are different from the usual response. @@ -36,7 +38,7 @@ def __init__(self): super().__init__() -class Form(Schema): +class Form(WoTSchema): """ Form hypermedia. diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py index a64e5083..b34c451b 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -1,26 +1,31 @@ """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 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.td.base import WoTSchema +from hololinked.td.data_schema import DataSchema +from hololinked.td.forms import Form +from hololinked.td.pydantic_extensions import type_to_dataschema +from hololinked.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 fields common to all interaction affordances, property, action or event. @@ -38,19 +43,6 @@ class InteractionAffordance(Schema): _custom_schema_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: """ @@ -102,25 +94,6 @@ 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. @@ -170,35 +143,7 @@ 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": - """ - Instantitate and build the schema for the specific interaction affordance. - - Use the `json()` method to get the JSON representation of the schema. - - Note that this method is different from `build()` method as its supposed to be used as a classmethod - to create an instance. Although, it internally calls `build()`, and some additional steps are included. - - 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" - Instance of this class with the schema fields populated. - """ - 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, TD: JSON) -> "PropertyAffordance | ActionAffordance | EventAffordance": """ Populate the schema from the TD and return it as an instance of this class. @@ -242,6 +187,8 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance affordance._thing_id = TD["id"] return affordance + from_TD: Callable[[str, JSON], PropertyAffordance | ActionAffordance | EventAffordance] = from_metadata + @classmethod def register_descriptor( cls, @@ -276,53 +223,6 @@ def register_descriptor( ) InteractionAffordance._custom_schema_generators[descriptor] = schema_generator - 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. - """ - 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): # noqa: D105 - return hash( - self.thing_id if self.thing_id else "" + self.thing_cls.__name__ if self.thing_cls else "" + self.name - ) - - def __str__(self): # noqa: D105 - if self.thing_cls: - return f"{self.__class__.__name__}({self.thing_cls.__name__}({self.thing_id}).{self.name})" - return f"{self.__class__.__name__}({self.name} of {self.thing_id})" - - def __eq__(self, value): # noqa: D105 - 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): # noqa: D105 if self.__class__ == PropertyAffordance: result = PropertyAffordance() @@ -336,19 +236,19 @@ def __deepcopy__(self, memo): # noqa: D105 setattr(result, k, copy.deepcopy(v, memo)) return result - def __getstate__(self): # noqa: D105 - 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) -> str: + """ + Return the JSON string representation of the schema. + + Returns + ------- + str + JSON string representation + """ + return WoTSchema.json(self) -class PropertyAffordance(DataSchema, InteractionAffordance): +class PropertyAffordance(DataSchema, InteractionAffordance, PropertyMetadata): """ Implements property affordance schema from `Property` descriptor object. @@ -373,7 +273,7 @@ def build(self) -> None: # noqa: D102 self.observable = property.observable @classmethod - def generate(cls, property, owner=None): # noqa: D102 + def generate(cls, property: Property, owner: Thing | ThingMeta = None): # noqa: D102 if not isinstance(property, Property): raise TypeError(f"property must be instance of Property, given type {type(property)}") affordance = PropertyAffordance() @@ -384,7 +284,7 @@ def generate(cls, property, owner=None): # noqa: D102 return affordance -class ActionAffordance(InteractionAffordance): +class ActionAffordance(InteractionAffordance, ActionMetadata): """ creates action affordance schema from actions (or methods). @@ -449,7 +349,7 @@ def build(self) -> None: # noqa: D102 self.safe = action.execution_info.safe @classmethod - def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": # noqa: D102 + def generate(cls, action: Action, owner: Thing | ThingMeta = None, **kwargs) -> "ActionAffordance": # noqa: D102 if not isinstance(action, Action): raise TypeError(f"action must be instance of Action, given type {type(action)}") affordance = ActionAffordance() @@ -460,7 +360,7 @@ def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": # noq return affordance -class EventAffordance(InteractionAffordance): +class EventAffordance(InteractionAffordance, EventMetadata): """ creates event affordance schema from events. @@ -498,7 +398,7 @@ def build(self) -> None: # noqa: D102 raise ValueError(f"unknown schema definition for event data, given type: {type(event.schema)}") @classmethod - def generate(cls, event: Event, owner, **kwargs) -> "EventAffordance": # noqa: D102 + def generate(cls, event: Event, owner: Thing | ThingMeta = None, **kwargs) -> "EventAffordance": # noqa: D102 if not isinstance(event, Event): raise TypeError(f"event must be instance of Event, given type {type(event)}") affordance = EventAffordance() diff --git a/hololinked/td/metadata.py b/hololinked/td/metadata.py index cb2ee768..5b99b819 100644 --- a/hololinked/td/metadata.py +++ b/hololinked/td/metadata.py @@ -1,13 +1,15 @@ """Include a general metadata like links, version info, etc. here.""" +from __future__ import annotations + from typing import Optional from pydantic import Field -from .base import Schema +from hololinked.td.base import WoTSchema -class Link(Schema): +class Link(WoTSchema): """ Impelements the Link schema for linking to other resources. @@ -20,7 +22,7 @@ class Link(Schema): type: Optional[str] = Field(default="application/json") -class VersionInfo(Schema): +class VersionInfo(WoTSchema): """ Represents version info. diff --git a/hololinked/td/security_definitions.py b/hololinked/td/security_definitions.py index 3411ef3c..56fd6a8f 100644 --- a/hololinked/td/security_definitions.py +++ b/hololinked/td/security_definitions.py @@ -1,13 +1,15 @@ """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.td.base import WoTSchema -class SecurityScheme(Schema): +class SecurityScheme(WoTSchema): """ Subclass from here to implement Security Scheme metadata. diff --git a/hololinked/td/tm.py b/hololinked/td/tm.py index 61ff148c..b3e4b5a6 100644 --- a/hololinked/td/tm.py +++ b/hololinked/td/tm.py @@ -4,23 +4,28 @@ Thing Descriptions are always generated by the specific protocols. """ +from __future__ import annotations + 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.td.base import WoTSchema +from hololinked.td.data_schema import DataSchema +from hololinked.td.interaction_affordance import ( ActionAffordance, EventAffordance, PropertyAffordance, ) -from .metadata import VersionInfo +from hololinked.td.metadata import VersionInfo + + +from hololinked.core import Thing # noqa # isort: skip +from hololinked.core.state_machine import BoundFSM # isort: skip -class ThingModel(Schema): +class ThingModel(WoTSchema, Metadata): """ Thing Model as per W3C WoT Thing Description v1.1. @@ -46,18 +51,19 @@ 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 + super().__init__( + 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": + def generate(self) -> ThingModel: """ Populate the thing model. @@ -65,12 +71,12 @@ def generate(self) -> "ThingModel": ------- ThingModel """ - self.id = self.instance.id - self.title = self.instance.__class__.__name__ + 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() @@ -101,9 +107,9 @@ def produce(self) -> Thing: 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], + ["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 name, obj in items: if name in skip_list or name in self.skip_names: @@ -111,19 +117,16 @@ def add_interaction_affordances(self) -> None: if ( name == "state" and affordance == "properties" - and ( - not hasattr(self.instance, "state_machine") - or not isinstance(self.instance.state_machine, BoundFSM) - ) + and (not hasattr(self.thing, "state_machine") or not isinstance(self.thing.state_machine, BoundFSM)) ): continue try: affordance_dict = getattr(self, affordance) - affordance_dict[name] = affordance_cls.generate(obj, self.instance) + affordance_dict[name] = affordance_cls.generate(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}") + self.thing.logger.error(f"Error while generating schema for {name} - {ex}") def model_dump(self, **kwargs) -> dict[str, Any]: """ diff --git a/hololinked/td/utils.py b/hololinked/td/utils.py index e2e3cf70..0caaa0b4 100644 --- a/hololinked/td/utils.py +++ b/hololinked/td/utils.py @@ -1,5 +1,7 @@ """utility functions for the TD module.""" +from __future__ import annotations + from typing import Optional From 05cd445674239e22330929ad57cd7496a47ee162 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Sun, 17 May 2026 09:13:33 +0200 Subject: [PATCH 03/11] fix typing and bugs --- hololinked/core/interfaces/metadata.py | 2 +- hololinked/server/repository.py | 11 +++++-- hololinked/server/security.py | 10 +++--- hololinked/server/server.py | 41 +++++++++++++++---------- hololinked/td/interaction_affordance.py | 24 +++++++++++++-- 5 files changed, 60 insertions(+), 28 deletions(-) diff --git a/hololinked/core/interfaces/metadata.py b/hololinked/core/interfaces/metadata.py index cbbcd9d2..46a0bda9 100644 --- a/hololinked/core/interfaces/metadata.py +++ b/hololinked/core/interfaces/metadata.py @@ -85,7 +85,7 @@ class InteractionMetadata(BaseModel): A property, action or event is called as an interaction affordance, and the metadata generated for it is called as interaction metadata. This base class defines metadata methods common to all of properties, - actions or events, and which could be common to different metadata standards. + actions or events, and could be common to different metadata or device description standards. """ _custom_schema_generators: dict diff --git a/hololinked/server/repository.py b/hololinked/server/repository.py index cb68cf1f..b5741d8e 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. 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/interaction_affordance.py b/hololinked/td/interaction_affordance.py index b34c451b..e1d2d5a0 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -5,7 +5,7 @@ import copy from enum import Enum -from typing import Any, Callable, ClassVar, Optional +from typing import Any, Callable, ClassVar, Optional # noqa: F401 from pydantic import BaseModel, ConfigDict, RootModel @@ -187,7 +187,27 @@ def from_metadata(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffor affordance._thing_id = TD["id"] return affordance - from_TD: Callable[[str, JSON], PropertyAffordance | ActionAffordance | EventAffordance] = from_metadata + @classmethod + def from_TD(self, name: str, TD: JSON) -> None: + """ + 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) + + Raises + ------ + ValueError + If the affordance type cannot be determined from the TD. + """ + return self.from_metadata(name, TD) @classmethod def register_descriptor( From f10e56fb26d176246bf10625f0e9abc3a1b19745 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Sun, 17 May 2026 09:24:58 +0200 Subject: [PATCH 04/11] ruff & ty --- hololinked/td/interaction_affordance.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py index e1d2d5a0..3b2c43fa 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -10,7 +10,12 @@ from pydantic import BaseModel, ConfigDict, RootModel from hololinked.constants import JSON, ResourceTypes -from hololinked.core.interfaces import ActionMetadata, EventMetadata, InteractionMetadata, PropertyMetadata +from hololinked.core.interfaces import ( + ActionMetadata, + EventMetadata, + InteractionMetadata, + PropertyMetadata, +) from hololinked.td.base import WoTSchema from hololinked.td.data_schema import DataSchema from hololinked.td.forms import Form From ae8ed094907b2e89d607c0cb99100d6b1ec3e237 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Sun, 17 May 2026 13:51:37 +0200 Subject: [PATCH 05/11] ruff and ty again --- .github/workflows/ci-pipeline.yml | 2 + hololinked/core/interfaces/metadata.py | 80 +++++++++++++++++++----- hololinked/td/base.py | 2 +- hololinked/td/data_schema.py | 29 +++++---- hololinked/td/forms.py | 12 ++-- hololinked/td/interaction_affordance.py | 82 ++++++++++++++++--------- hololinked/td/pydantic_extensions.py | 18 +++--- hololinked/td/security_definitions.py | 10 ++- hololinked/td/tm.py | 45 +++++++++++--- 9 files changed, 195 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 428ce217..c33b1975 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -83,6 +83,7 @@ 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/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 @@ -95,6 +96,7 @@ jobs: ty check hololinked/serializers ty check hololinked/schema_validators ty check hololinked/storage + ty check hololinked/td ty check hololinked/serialization.py ty check hololinked/schemas.py ty check hololinked/persistence.py diff --git a/hololinked/core/interfaces/metadata.py b/hololinked/core/interfaces/metadata.py index 46a0bda9..55d38515 100644 --- a/hololinked/core/interfaces/metadata.py +++ b/hololinked/core/interfaces/metadata.py @@ -3,11 +3,11 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self from pydantic import BaseModel, ConfigDict -from hololinked.constants import JSON, ResourceTypes +from hololinked.constants import ResourceTypes if TYPE_CHECKING: @@ -88,7 +88,7 @@ class InteractionMetadata(BaseModel): actions or events, and could be common to different metadata or device description standards. """ - _custom_schema_generators: dict + _custom_schema_generators: ClassVar[dict] def __init__(self): super().__init__() @@ -119,22 +119,70 @@ def owner_cls(self) -> ThingMeta: @property def objekt(self) -> Property | Action | Event: - """Object instance of the interaction, instance of `Property`, `Action` or `Event`.""" + """ + Object instance of the interaction, instance of `Property`, `Action` or `Event`. + + Raises + ------ + AttributeError + If the metadata is not bound to any interaction affordance 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 method to generate the metadata + from a property object, and 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.""" + """ + 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 affordance 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 method 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.""" + """ + 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 a `Thing` instance, but created manually or from a + different source. Use `thing.properties.descriptors[].to_metadata()` + only method to generate the metadata from a property object, and similarly for 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.""" + """ + `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: @@ -145,8 +193,8 @@ def build(self) -> None: def generate( cls, interaction: Property | Action | Event, - owner: Thing | ThingMeta = None, - ) -> PropertyMetadata | ActionMetadata | EventMetadata: + owner: Thing | ThingMeta, + ) -> Self: """ Instantitate and build the metadata for the specific interaction. @@ -170,7 +218,7 @@ def generate( raise NotImplementedError("generate() must be implemented in subclass of InteractionMetadata") @classmethod - def from_metadata(cls, name: str, metadata: JSON) -> PropertyMetadata | ActionMetadata | EventMetadata: + 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. @@ -185,7 +233,7 @@ def from_metadata(cls, name: str, metadata: JSON) -> PropertyMetadata | ActionMe Returns ------- - PropertyMetadata | ActionMetadata | EventMetadata + Self Instance of this class. Raises @@ -199,7 +247,7 @@ def from_metadata(cls, name: str, metadata: JSON) -> PropertyMetadata | ActionMe def register_descriptor( cls, descriptor: Property | Action | Event, - schema_generator: InteractionMetadata, + schema_generator: type[InteractionMetadata], ) -> None: """ Register a custom schema generator for a descriptor. @@ -208,7 +256,7 @@ def register_descriptor( ---------- descriptor: Property | Action | Event The descriptor class - schema_generator: InteractionMetadata + schema_generator: type[InteractionMetadata] `InteractionMetadata` subclass that implements the custom schema generation logic for the descriptor. Either override the `generate()` method or the `build()` method. @@ -294,7 +342,7 @@ def what(self) -> Enum: # noqa: D102 return ResourceTypes.PROPERTY @classmethod - def generate(cls, property: Property, owner: Thing | ThingMeta = None) -> PropertyMetadata: # noqa: D102 + def generate(cls, property: Property, owner: Thing | ThingMeta) -> PropertyMetadata: # noqa: D102 raise NotImplementedError @@ -306,7 +354,7 @@ def what(self) -> Enum: # noqa: D102 return ResourceTypes.ACTION @classmethod - def generate(cls, action: Action, owner: Thing | ThingMeta = None) -> ActionMetadata: # noqa: D102 + def generate(cls, action: Action, owner: Thing | ThingMeta) -> ActionMetadata: # noqa: D102 raise NotImplementedError @@ -318,5 +366,5 @@ def what(self) -> Enum: # noqa: D102 return ResourceTypes.EVENT @classmethod - def generate(cls, event: Event, owner: Thing | ThingMeta = None) -> EventMetadata: # noqa: D102 + def generate(cls, event: Event, owner: Thing | ThingMeta) -> EventMetadata: # noqa: D102 raise NotImplementedError diff --git a/hololinked/td/base.py b/hololinked/td/base.py index e69d4393..a690fa82 100644 --- a/hololinked/td/base.py +++ b/hololinked/td/base.py @@ -42,7 +42,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]: ] return super().model_dump(**kwargs) - def json(self) -> dict[str, Any]: # noqa + def json(self) -> dict[str, Any]: # ty: ignore[invalid-method-override] """ Same as model_dump. diff --git a/hololinked/td/data_schema.py b/hololinked/td/data_schema.py index 1dc12c78..f25697fd 100644 --- a/hololinked/td/data_schema.py +++ b/hololinked/td/data_schema.py @@ -39,9 +39,9 @@ class DataSchema(WoTSchema): - [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 @@ -51,7 +51,7 @@ class DataSchema(WoTSchema): 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() @@ -61,7 +61,8 @@ def __init__(self): def ds_build_fields_from_property(self, property: Property) -> None: """Populates schema information from property descriptor object.""" - self.title = get_summary(property.doc) + if property.doc: + self.title = get_summary(property.doc) if property.constant: self.const = property.constant if property.readonly: @@ -71,8 +72,7 @@ def ds_build_fields_from_property(self, property: Property) -> None: if 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"] @@ -153,6 +153,12 @@ def _move_own_type_to_oneOf(self): """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): """ @@ -268,7 +274,7 @@ class ArraySchema(DataSchema): 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) @@ -334,6 +340,8 @@ 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"] @@ -450,14 +458,13 @@ def ds_build_fields_from_property(self, property) -> None: # noqa: D102 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/td/forms.py index 02713f6b..49db017d 100644 --- a/hololinked/td/forms.py +++ b/hololinked/td/forms.py @@ -6,7 +6,6 @@ from pydantic import Field -from hololinked.constants import JSON from hololinked.td.base import WoTSchema @@ -32,7 +31,8 @@ class AdditionalExpectedResponse(WoTSchema): 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__() @@ -45,10 +45,10 @@ class Form(WoTSchema): 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: Optional[str] = None + 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 diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py index 3b2c43fa..9159b7fc 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -5,7 +5,7 @@ import copy from enum import Enum -from typing import Any, Callable, ClassVar, Optional # noqa: F401 +from typing import Any, Callable, ClassVar, Optional, Self, cast # noqa: F401 from pydantic import BaseModel, ConfigDict, RootModel @@ -54,7 +54,15 @@ def owner(self) -> Thing: Owning `Thing` instance or `Thing` class of the interaction affordance. 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 affordance is not properly bound to a `Thing` + instance or class. """ + if self._owner is None: + raise AttributeError("owner is not set for this affordance") return self._owner @owner.setter @@ -75,7 +83,17 @@ def owner(self, value): @property def objekt(self) -> Property | Action | Event: - """Object instance of the interaction affordance, instance of `Property`, `Action` or `Event`.""" + """ + Object instance of the interaction affordance, instance of `Property`, `Action` or `Event`. + + Raises + ------ + AttributeError + If the object is not set, which means this affordance is not properly bound to a + `Property`, `Action` or `Event` instance. + """ + if self._objekt is None: + raise AttributeError("Metadata bound to unknown object (property, action or event).") return self._objekt @objekt.setter @@ -148,7 +166,7 @@ def pop_form(self, op: str, default: Any = None) -> Form: return default @classmethod - def from_metadata(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. @@ -159,12 +177,12 @@ def from_metadata(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffor ---------- 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 @@ -180,7 +198,7 @@ def from_metadata(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffor 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: @@ -189,11 +207,11 @@ def from_metadata(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffor 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) -> None: + def from_TD(self, name: str, TD: JSON) -> Self: """ Populate the schema from the TD and return it as an instance of this class. @@ -207,6 +225,11 @@ def from_TD(self, name: str, TD: JSON) -> None: 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 @@ -218,7 +241,7 @@ def from_TD(self, name: str, TD: JSON) -> None: def register_descriptor( cls, descriptor: Property | Action | Event, - schema_generator: "InteractionAffordance", + schema_generator: type[InteractionAffordance] | type[InteractionMetadata], ) -> None: """ Register a custom schema generator for a descriptor. @@ -261,14 +284,14 @@ def __deepcopy__(self, memo): # noqa: D105 setattr(result, k, copy.deepcopy(v, memo)) return result - def json(self) -> str: + def json(self) -> dict[str, Any]: """ - Return the JSON string representation of the schema. + Return the JSON representation. Returns ------- - str - JSON string representation + dict[str, Any] + JSON representation """ return WoTSchema.json(self) @@ -292,13 +315,13 @@ def what(self) -> Enum: # noqa: D102 return ResourceTypes.PROPERTY def build(self) -> None: # noqa: D102 - property = self.objekt + property = cast(Property, self.objekt) self.ds_build_from_property(property) if property.observable: self.observable = property.observable @classmethod - def generate(cls, property: Property, owner: Thing | ThingMeta = None): # noqa: D102 + def generate(cls, property: Property, owner: Thing | ThingMeta): # noqa: D102 if not isinstance(property, Property): raise TypeError(f"property must be instance of Property, given type {type(property)}") affordance = PropertyAffordance() @@ -318,11 +341,11 @@ class ActionAffordance(InteractionAffordance, ActionMetadata): """ # [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__() @@ -332,7 +355,7 @@ def what(self): # noqa: D102 return ResourceTypes.ACTION def build(self) -> None: # noqa: D102 - action = self.objekt # type: Action + action = cast(Action, self.objekt) if action.obj.__doc__: title = get_summary(action.obj.__doc__) description = self.format_doc(action.obj.__doc__) @@ -374,7 +397,7 @@ def build(self) -> None: # noqa: D102 self.safe = action.execution_info.safe @classmethod - def generate(cls, action: Action, owner: Thing | ThingMeta = None, **kwargs) -> "ActionAffordance": # noqa: D102 + def generate(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() @@ -394,8 +417,8 @@ class EventAffordance(InteractionAffordance, EventMetadata): """ # [Supported Fields]()
- subscription: str = None - data: JSON = None + subscription: Optional[str] = None + data: Optional[JSON] = None def __init__(self): super().__init__() @@ -405,10 +428,11 @@ def what(self): # noqa: D102 return ResourceTypes.EVENT def build(self) -> None: # noqa: D102 - event = self.objekt # type: Event - if event.__doc__: - title = get_summary(event.doc) - description = self.format_doc(event.doc) + 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 @@ -423,7 +447,7 @@ def build(self) -> None: # noqa: D102 raise ValueError(f"unknown schema definition for event data, given type: {type(event.schema)}") @classmethod - def generate(cls, event: Event, owner: Thing | ThingMeta = None, **kwargs) -> "EventAffordance": # noqa: D102 + def generate(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/td/pydantic_extensions.py b/hololinked/td/pydantic_extensions.py index 301b5ac3..596ef356 100644 --- a/hololinked/td/pydantic_extensions.py +++ b/hololinked/td/pydantic_extensions.py @@ -69,7 +69,7 @@ def is_a_reference(d: JSONSchemaType) -> bool: 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. @@ -101,7 +101,7 @@ def look_up_reference(reference: str, d: JSONSchemaType) -> JSONSchemaType: "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 @@ -232,7 +232,7 @@ def convert_additionalproperties(d: JSONSchemaType) -> JSONSchemaType: """ 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"] @@ -260,11 +260,11 @@ def check_recursion(depth: int, limit: int): 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. @@ -319,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 @@ -327,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 @@ -366,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 diff --git a/hololinked/td/security_definitions.py b/hololinked/td/security_definitions.py index 56fd6a8f..800dd921 100644 --- a/hololinked/td/security_definitions.py +++ b/hololinked/td/security_definitions.py @@ -16,8 +16,8 @@ class SecurityScheme(WoTSchema): 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 @@ -66,7 +66,11 @@ class OIDCSecurityScheme(SecurityScheme): token: str = "" scopes: list[str] = Field(default_factory=list) - def build(self, token_url: str, scopes: list[str] | None = ["openid"]): # noqa: D102 + 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/td/tm.py index b3e4b5a6..85918ee4 100644 --- a/hololinked/td/tm.py +++ b/hololinked/td/tm.py @@ -6,6 +6,7 @@ from __future__ import annotations +from collections.abc import ItemsView from typing import Any, Optional from pydantic import ConfigDict, Field @@ -35,8 +36,8 @@ class ThingModel(WoTSchema, Metadata): 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 @@ -56,7 +57,9 @@ def __init__( ignore_errors: bool = False, skip_names: Optional[list[str]] = [], ) -> None: - super().__init__( + WoTSchema.__init__(self) + Metadata.__init__( + self, thing=thing, ignore_errors=ignore_errors, skip_names=skip_names, @@ -70,7 +73,15 @@ def generate(self) -> ThingModel: 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 instantiated. This method is not to be used at that time. """ + if self.thing is None: + raise ValueError("This object is 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"] @@ -105,12 +116,25 @@ 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.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], - ]: + """ + Add interaction affordances to 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 instantiated. This method is not to be used at that time. + """ + if self.thing is None: + raise ValueError("This object is 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 @@ -126,7 +150,8 @@ def add_interaction_affordances(self) -> None: except Exception as ex: if not self.ignore_errors: raise ex from None - self.thing.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]: """ From 485c7ed60352170a528f50cf7f84f4acb89bfb1e Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Wed, 20 May 2026 19:12:30 +0200 Subject: [PATCH 06/11] update doc --- hololinked/client/factory.py | 12 +-- hololinked/core/interfaces/metadata.py | 108 ++++++++++++++++-------- hololinked/core/thing.py | 8 +- hololinked/td/__init__.py | 2 +- hololinked/td/forms.py | 2 +- hololinked/td/interaction_affordance.py | 18 ++-- hololinked/td/pydantic_extensions.py | 9 +- hololinked/td/tm.py | 21 ++--- hololinked/td/utils.py | 4 +- 9 files changed, 107 insertions(+), 77 deletions(-) diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py index bdb03e38..a4775e7c 100644 --- a/hololinked/client/factory.py +++ b/hololinked/client/factory.py @@ -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/core/interfaces/metadata.py b/hololinked/core/interfaces/metadata.py index 55d38515..6629a7d3 100644 --- a/hololinked/core/interfaces/metadata.py +++ b/hololinked/core/interfaces/metadata.py @@ -29,7 +29,7 @@ def __init__( skip_names: Optional[list[str]] = [], ) -> None: """ - Initialize the Metadata handler. + Initialize the Metadata. Parameters ---------- @@ -47,25 +47,38 @@ def __init__( self.skip_names = skip_names or [] def generate(self) -> Metadata: - """Populate the metadata.""" - raise NotImplementedError("implement generate() in subclass") + """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("This will be implemented in a future release for an API first approach") + raise NotImplementedError("Implement produce() in subclass") skip_properties: list[str] - """list of default property names to skip when generating the metadata.""" + """ + 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.""" + """ + 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.""" + """ + 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.""" - ... + """ + 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]: """ @@ -83,9 +96,10 @@ 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 called 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. + 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_schema_generators: ClassVar[dict] @@ -101,7 +115,7 @@ def __init__(self): @property def what(self) -> Enum: """Whether it is a property, action or event.""" - raise NotImplementedError("Unknown interaction (property, action, or event?) implement in subclass") + raise NotImplementedError("Unknown interaction (property, action, or event?), implement in subclass") @property def owner(self) -> Thing: @@ -109,12 +123,32 @@ 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.""" + """ + 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 @@ -125,10 +159,9 @@ def objekt(self) -> Property | Action | Event: Raises ------ AttributeError - If the metadata is not bound to any interaction affordance 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 method to generate the metadata - from a property object, and similarly for action and event. + 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).") @@ -142,9 +175,9 @@ def name(self) -> str: Raises ------ AttributeError - If the metadata is not bound to any interaction affordance object. This usually happens + 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 method to generate the metadata + 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: @@ -160,9 +193,9 @@ def thing_id(self) -> str: ------ AttributeError If the metadata is not bound to any `Thing` instance. This usually happens - when the metadata is not generated from a `Thing` instance, but created manually or from a + 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 method to generate the metadata from a property object, and similarly for action and event. + 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).") @@ -190,7 +223,7 @@ def build(self) -> None: raise NotImplementedError("build must be implemented in subclass of InteractionMetadata") @classmethod - def generate( + def from_descriptor( cls, interaction: Property | Action | Event, owner: Thing | ThingMeta, @@ -215,7 +248,7 @@ def generate( PropertyMetadata | ActionMetadata | EventMetadata Instance of this class with the metadata fields populated. """ - raise NotImplementedError("generate() must be implemented in subclass of InteractionMetadata") + raise NotImplementedError("from_descriptor() must be implemented in subclass of InteractionMetadata") @classmethod def from_metadata(cls, name: str, metadata: dict[str, Any]) -> Self: @@ -247,23 +280,23 @@ def from_metadata(cls, name: str, metadata: dict[str, Any]) -> Self: def register_descriptor( cls, descriptor: Property | Action | Event, - schema_generator: type[InteractionMetadata], + metadata_generator: type[InteractionMetadata], ) -> None: """ - Register a custom schema generator for a descriptor. + Register a custom metadata generator for a descriptor. Parameters ---------- descriptor: Property | Action | Event The descriptor class - schema_generator: type[InteractionMetadata] - `InteractionMetadata` subclass that implements the custom schema generation logic for the descriptor. - Either override the `generate()` method or the `build()` method. + 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 schema generator is not an + 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 @@ -276,7 +309,8 @@ 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. + 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": @@ -335,36 +369,36 @@ def json(self) -> dict[str, Any]: class PropertyMetadata(InteractionMetadata): - """Implements property affordance schema from `Property` descriptor object.""" + """Generate property metadata from `Property` descriptor object.""" @property def what(self) -> Enum: # noqa: D102 return ResourceTypes.PROPERTY @classmethod - def generate(cls, property: Property, owner: Thing | ThingMeta) -> PropertyMetadata: # noqa: D102 + def from_descriptor(cls, property: Property, owner: Thing | ThingMeta) -> PropertyMetadata: # noqa: D102 raise NotImplementedError class ActionMetadata(InteractionMetadata): - """Implements action affordance schema from `Action` descriptor object.""" + """Generate action metadata from `Action` descriptor object.""" @property def what(self) -> Enum: # noqa: D102 return ResourceTypes.ACTION @classmethod - def generate(cls, action: Action, owner: Thing | ThingMeta) -> ActionMetadata: # noqa: D102 + def from_descriptor(cls, action: Action, owner: Thing | ThingMeta) -> ActionMetadata: # noqa: D102 raise NotImplementedError class EventMetadata(InteractionMetadata): - """Implements event affordance schema from `Event` descriptor object.""" + """Generate event metadata from `Event` descriptor object.""" @property def what(self) -> Enum: # noqa: D102 return ResourceTypes.EVENT @classmethod - def generate(cls, event: Event, owner: Thing | ThingMeta) -> EventMetadata: # noqa: D102 + def from_descriptor(cls, event: Event, owner: Thing | ThingMeta) -> EventMetadata: # noqa: D102 raise NotImplementedError diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index de593c32..41d32dfd 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -23,10 +23,10 @@ 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. + 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) """ diff --git a/hololinked/td/__init__.py b/hololinked/td/__init__.py index 50f1dc88..e7aab02c 100644 --- a/hololinked/td/__init__.py +++ b/hololinked/td/__init__.py @@ -1,4 +1,4 @@ -"""W3C Web of Things based Thing Descriptions (TD) and models (TM).""" +"""W3C Web of Things based Thing Descriptions (TD) and Models (TM).""" from .interaction_affordance import ( # noqa: F401 ActionAffordance, diff --git a/hololinked/td/forms.py b/hololinked/td/forms.py index 49db017d..c51daec4 100644 --- a/hololinked/td/forms.py +++ b/hololinked/td/forms.py @@ -45,7 +45,7 @@ class Form(WoTSchema): https://www.w3.org/TR/wot-thing-description11/#form """ - href: Optional[str] = None + 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") diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py index 9159b7fc..6132114a 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -58,8 +58,9 @@ def owner(self) -> Thing: Raises ------ AttributeError - If the owner is not set, which means this affordance is not properly bound to a `Thing` - instance or class. + 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") @@ -89,8 +90,9 @@ def objekt(self) -> Property | Action | Event: Raises ------ AttributeError - If the object is not set, which means this affordance is not properly bound to a - `Property`, `Action` or `Event` instance. + 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).") @@ -252,7 +254,7 @@ def register_descriptor( The descriptor class schema_generator: `InteractionAffordance` `InteractionAffordance` subclass that implements the custom schema generation logic for the descriptor. - Either override the `generate()` method or the `build()` method. + Either override the `from_descriptor()` method or the `build()` method. Raises ------ @@ -321,7 +323,7 @@ def build(self) -> None: # noqa: D102 self.observable = property.observable @classmethod - def generate(cls, property: Property, owner: Thing | ThingMeta): # noqa: D102 + def from_descriptor(cls, property: Property, owner: Thing | ThingMeta): # noqa: D102 if not isinstance(property, Property): raise TypeError(f"property must be instance of Property, given type {type(property)}") affordance = PropertyAffordance() @@ -397,7 +399,7 @@ def build(self) -> None: # noqa: D102 self.safe = action.execution_info.safe @classmethod - def generate(cls, action: Action, owner: Thing | ThingMeta, **kwargs) -> "ActionAffordance": # noqa: D102 + 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() @@ -447,7 +449,7 @@ def build(self) -> None: # noqa: D102 raise ValueError(f"unknown schema definition for event data, given type: {type(event.schema)}") @classmethod - def generate(cls, event: Event, owner: Thing | ThingMeta, **kwargs) -> "EventAffordance": # noqa: D102 + 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/td/pydantic_extensions.py b/hololinked/td/pydantic_extensions.py index 596ef356..028205ea 100644 --- a/hololinked/td/pydantic_extensions.py +++ b/hololinked/td/pydantic_extensions.py @@ -349,9 +349,9 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: Parameters ---------- - t : type or BaseModel + t: type or BaseModel The Python type or pydantic model to convert. - **kwargs : Any + **kwargs: dict[str, Any] Additional fields to merge into the resulting DataSchema, overriding any auto-generated values. @@ -386,11 +386,12 @@ class GenerateJsonSchemaWithoutDefaultTitles(GenerateJsonSchema): # 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. + """ + Return False for core schemas to suppress autogenerated field titles. Parameters ---------- - schema : CoreSchemaOrField + schema: CoreSchemaOrField The pydantic core schema or field schema being evaluated. Returns diff --git a/hololinked/td/tm.py b/hololinked/td/tm.py index 85918ee4..33c51662 100644 --- a/hololinked/td/tm.py +++ b/hololinked/td/tm.py @@ -23,7 +23,6 @@ from hololinked.core import Thing # noqa # isort: skip -from hololinked.core.state_machine import BoundFSM # isort: skip class ThingModel(WoTSchema, Metadata): @@ -78,10 +77,10 @@ def generate(self) -> ThingModel: ------ 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 instantiated. This method is not to be used at that time. + 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("This object is not attached to a Thing instance to generate metadata.") + 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"] @@ -117,16 +116,16 @@ def produce(self) -> Thing: def add_interaction_affordances(self) -> None: """ - Add interaction affordances to thing model. + 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 instantiated. This method is not to be used at that time. + 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("This object is not attached to a Thing instance to generate metadata.") + 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]] ] = [ @@ -138,15 +137,11 @@ def add_interaction_affordances(self) -> None: 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.thing, "state_machine") or not isinstance(self.thing.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.thing) + affordance_dict[name] = affordance_cls.from_descriptor(obj, self.thing) except Exception as ex: if not self.ignore_errors: raise ex from None @@ -155,7 +150,7 @@ def add_interaction_affordances(self) -> None: def model_dump(self, **kwargs) -> dict[str, Any]: """ - Return the JSON representation of the schema. + Return the JSON representation. Returns ------- diff --git a/hololinked/td/utils.py b/hololinked/td/utils.py index 0caaa0b4..7a38c071 100644 --- a/hololinked/td/utils.py +++ b/hololinked/td/utils.py @@ -2,10 +2,8 @@ from __future__ import annotations -from typing import Optional - -def get_summary(docs: str) -> Optional[str]: +def get_summary(docs: str) -> str: """ Return the first line of the docstring of an object. From 728dddf3afe800d0fd5afc2ba2abe728b62b21fe Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Wed, 20 May 2026 19:16:58 +0200 Subject: [PATCH 07/11] change top level folder --- hololinked/client/abstractions.py | 4 ++-- hololinked/client/factory.py | 2 +- hololinked/client/http/consumed_interactions.py | 4 ++-- hololinked/client/mqtt/consumed_interactions.py | 4 ++-- hololinked/client/zmq/consumed_interactions.py | 4 ++-- hololinked/core/actions.py | 10 +++++++--- hololinked/core/events.py | 2 +- hololinked/core/property.py | 2 +- hololinked/core/thing.py | 4 ++-- hololinked/{ => metadata}/td/__init__.py | 0 hololinked/{ => metadata}/td/base.py | 0 hololinked/{ => metadata}/td/data_schema.py | 4 ++-- hololinked/{ => metadata}/td/forms.py | 2 +- .../{ => metadata}/td/interaction_affordance.py | 10 +++++----- hololinked/{ => metadata}/td/metadata.py | 2 +- hololinked/{ => metadata}/td/pydantic_extensions.py | 0 hololinked/{ => metadata}/td/security_definitions.py | 2 +- hololinked/{ => metadata}/td/tm.py | 8 ++++---- hololinked/{ => metadata}/td/utils.py | 0 tests/test_06_actions.py | 2 +- tests/test_08_events.py | 2 +- tests/test_09_rpc_broker.py | 11 +++++------ tests/test_10_thing_description.py | 4 ++-- 23 files changed, 43 insertions(+), 40 deletions(-) rename hololinked/{ => metadata}/td/__init__.py (100%) rename hololinked/{ => metadata}/td/base.py (100%) rename hololinked/{ => metadata}/td/data_schema.py (99%) rename hololinked/{ => metadata}/td/forms.py (98%) rename hololinked/{ => metadata}/td/interaction_affordance.py (98%) rename hololinked/{ => metadata}/td/metadata.py (92%) rename hololinked/{ => metadata}/td/pydantic_extensions.py (100%) rename hololinked/{ => metadata}/td/security_definitions.py (97%) rename hololinked/{ => metadata}/td/tm.py (96%) rename hololinked/{ => metadata}/td/utils.py (100%) 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 a4775e7c..a26709c4 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, 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..8502b2c5 100644 --- a/hololinked/client/mqtt/consumed_interactions.py +++ b/hololinked/client/mqtt/consumed_interactions.py @@ -12,8 +12,8 @@ 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 7c05046f..1e0bfe0a 100644 --- a/hololinked/client/zmq/consumed_interactions.py +++ b/hololinked/client/zmq/consumed_interactions.py @@ -35,8 +35,8 @@ ResponseMessage, ) from hololinked.core.zmq.payloads import SerializableData -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 __error_message_types__ = [TIMEOUT, ERROR, INVALID_MESSAGE] diff --git a/hololinked/core/actions.py b/hololinked/core/actions.py index fc4781a9..686eca2b 100644 --- a/hololinked/core/actions.py +++ b/hololinked/core/actions.py @@ -3,7 +3,7 @@ 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 @@ -24,6 +24,10 @@ from .dataklasses import ActionInfoValidator +if TYPE_CHECKING: + from hololinked.core.thing import Thing + + class Action: """ Object that models an action. @@ -91,7 +95,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_affordance(self, owner_inst: Thing | None = None): """ Generates a `ActionAffordance` TD fragment for this Action. @@ -107,7 +111,7 @@ def to_affordance(self, owner_inst=None): """ from ..td import ActionAffordance - return ActionAffordance.generate(self, owner_inst or self.owner) + return ActionAffordance.from_descriptor(self, owner_inst or self.owner) class BoundAction: diff --git a/hololinked/core/events.py b/hololinked/core/events.py index f7130764..104b965b 100644 --- a/hololinked/core/events.py +++ b/hololinked/core/events.py @@ -80,7 +80,7 @@ def to_affordance(self, owner_inst: Any = None): """ from ..td import EventAffordance - return EventAffordance.generate(self, owner_inst or self.owner) + return EventAffordance.from_descriptor(self, owner_inst or self.owner) class EventDispatcher: diff --git a/hololinked/core/property.py b/hololinked/core/property.py index f9d855a3..4127e8bf 100644 --- a/hololinked/core/property.py +++ b/hololinked/core/property.py @@ -325,7 +325,7 @@ def to_affordance(self, owner_inst=None): """ from ..td import PropertyAffordance - return PropertyAffordance.generate(self, owner_inst or self.owner) + return PropertyAffordance.from_descriptor(self, owner_inst or self.owner) class ModelRoot(RootModel): diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index 41d32dfd..6fd56ec6 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -225,13 +225,13 @@ 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 hololinked.td.tm import ThingModel + from hololinked.metadata.td.tm import ThingModel return ThingModel(thing=self, ignore_errors=ignore_errors, skip_names=skip_names).generate() diff --git a/hololinked/td/__init__.py b/hololinked/metadata/td/__init__.py similarity index 100% rename from hololinked/td/__init__.py rename to hololinked/metadata/td/__init__.py diff --git a/hololinked/td/base.py b/hololinked/metadata/td/base.py similarity index 100% rename from hololinked/td/base.py rename to hololinked/metadata/td/base.py diff --git a/hololinked/td/data_schema.py b/hololinked/metadata/td/data_schema.py similarity index 99% rename from hololinked/td/data_schema.py rename to hololinked/metadata/td/data_schema.py index f25697fd..83d73d44 100644 --- a/hololinked/td/data_schema.py +++ b/hololinked/metadata/td/data_schema.py @@ -26,8 +26,8 @@ TypedKeyMappingsDict, TypedList, ) -from hololinked.td.base import WoTSchema -from hololinked.td.utils import get_summary +from hololinked.metadata.td.base import WoTSchema +from hololinked.metadata.td.utils import get_summary from hololinked.utils import issubklass diff --git a/hololinked/td/forms.py b/hololinked/metadata/td/forms.py similarity index 98% rename from hololinked/td/forms.py rename to hololinked/metadata/td/forms.py index c51daec4..a24065ab 100644 --- a/hololinked/td/forms.py +++ b/hololinked/metadata/td/forms.py @@ -6,7 +6,7 @@ from pydantic import Field -from hololinked.td.base import WoTSchema +from hololinked.metadata.td.base import WoTSchema class ExpectedResponse(WoTSchema): diff --git a/hololinked/td/interaction_affordance.py b/hololinked/metadata/td/interaction_affordance.py similarity index 98% rename from hololinked/td/interaction_affordance.py rename to hololinked/metadata/td/interaction_affordance.py index 6132114a..7c5bd135 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/metadata/td/interaction_affordance.py @@ -16,11 +16,11 @@ InteractionMetadata, PropertyMetadata, ) -from hololinked.td.base import WoTSchema -from hololinked.td.data_schema import DataSchema -from hololinked.td.forms import Form -from hololinked.td.pydantic_extensions import type_to_dataschema -from hololinked.td.utils import get_summary +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 diff --git a/hololinked/td/metadata.py b/hololinked/metadata/td/metadata.py similarity index 92% rename from hololinked/td/metadata.py rename to hololinked/metadata/td/metadata.py index 5b99b819..2cd8ecc5 100644 --- a/hololinked/td/metadata.py +++ b/hololinked/metadata/td/metadata.py @@ -6,7 +6,7 @@ from pydantic import Field -from hololinked.td.base import WoTSchema +from hololinked.metadata.td.base import WoTSchema class Link(WoTSchema): diff --git a/hololinked/td/pydantic_extensions.py b/hololinked/metadata/td/pydantic_extensions.py similarity index 100% rename from hololinked/td/pydantic_extensions.py rename to hololinked/metadata/td/pydantic_extensions.py diff --git a/hololinked/td/security_definitions.py b/hololinked/metadata/td/security_definitions.py similarity index 97% rename from hololinked/td/security_definitions.py rename to hololinked/metadata/td/security_definitions.py index 800dd921..bce19b76 100644 --- a/hololinked/td/security_definitions.py +++ b/hololinked/metadata/td/security_definitions.py @@ -6,7 +6,7 @@ from pydantic import Field -from hololinked.td.base import WoTSchema +from hololinked.metadata.td.base import WoTSchema class SecurityScheme(WoTSchema): diff --git a/hololinked/td/tm.py b/hololinked/metadata/td/tm.py similarity index 96% rename from hololinked/td/tm.py rename to hololinked/metadata/td/tm.py index 33c51662..fa338c75 100644 --- a/hololinked/td/tm.py +++ b/hololinked/metadata/td/tm.py @@ -12,14 +12,14 @@ from pydantic import ConfigDict, Field from hololinked.core.interfaces import Metadata -from hololinked.td.base import WoTSchema -from hololinked.td.data_schema import DataSchema -from hololinked.td.interaction_affordance import ( +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 hololinked.td.metadata import VersionInfo +from hololinked.metadata.td.metadata import VersionInfo from hololinked.core import Thing # noqa # isort: skip diff --git a/hololinked/td/utils.py b/hololinked/metadata/td/utils.py similarity index 100% rename from hololinked/td/utils.py rename to hololinked/metadata/td/utils.py diff --git a/tests/test_06_actions.py b/tests/test_06_actions.py index 6be91b62..9fd7fa1d 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 diff --git a/tests/test_08_events.py b/tests/test_08_events.py index e7a03858..63e2c473 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 diff --git a/tests/test_09_rpc_broker.py b/tests/test_09_rpc_broker.py index 1183212e..aa3e5e70 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 @@ -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,8 +618,7 @@ def test_18_sync_client_event_stream( event_name: str, expected_data: Any, ): - """test if event can be streamed by a synchronous threaded client""" - + """Test if event can be streamed by a synchronous threaded client""" resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance form = Form() @@ -676,7 +675,7 @@ 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""" + """Test if event can be streamed by an asynchronous client in an async loop""" resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance form = Form() diff --git a/tests/test_10_thing_description.py b/tests/test_10_thing_description.py index 2b411945..477a66c7 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, From 3aa2478edeed2c0d177347aeff037023e272b8c4 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Fri, 22 May 2026 17:51:27 +0200 Subject: [PATCH 08/11] introduce top level file for injection of metadata generation formats --- .github/workflows/ci-pipeline.yml | 6 +- hololinked/core/actions.py | 11 ++- hololinked/core/events.py | 21 ++++-- hololinked/core/interfaces/metadata.py | 2 +- hololinked/core/property.py | 74 +++++++++++++++---- hololinked/core/thing.py | 10 ++- hololinked/ddl.py | 60 +++++++++++++++ .../metadata/td/interaction_affordance.py | 16 ++-- hololinked/utils.py | 21 +++--- 9 files changed, 170 insertions(+), 51 deletions(-) create mode 100644 hololinked/ddl.py diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index c33b1975..fbc4a334 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -83,10 +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/td + 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' @@ -96,10 +97,11 @@ jobs: ty check hololinked/serializers ty check hololinked/schema_validators ty check hololinked/storage - ty check hololinked/td + 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/core/actions.py b/hololinked/core/actions.py index 686eca2b..8378a8a7 100644 --- a/hololinked/core/actions.py +++ b/hololinked/core/actions.py @@ -12,6 +12,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, @@ -25,6 +26,7 @@ if TYPE_CHECKING: + from hololinked.core.interfaces import ActionMetadata from hololinked.core.thing import Thing @@ -95,7 +97,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: Thing | None = None): + def to_affordance(self, owner_inst: Thing | None = None, format: str = "wot") -> ActionMetadata: """ Generates a `ActionAffordance` TD fragment for this Action. @@ -109,9 +111,12 @@ def to_affordance(self, owner_inst: Thing | None = None): ActionAffordance the affordance TD fragment for this action """ - from ..td import ActionAffordance + from hololinked.ddl import MetadataFormats - return ActionAffordance.from_descriptor(self, owner_inst or self.owner) + return MetadataFormats.generator_class(format).action.from_descriptor( + self, + owner_inst or self.owner, + ) class BoundAction: diff --git a/hololinked/core/events.py b/hololinked/core/events.py index 104b965b..ea9ba7bd 100644 --- a/hololinked/core/events.py +++ b/hololinked/core/events.py @@ -1,10 +1,17 @@ -from typing import Any, overload +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 # TODO remove this if possible class Event: @@ -64,7 +71,7 @@ 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_affordance(self, owner_inst: Thing | None = None, format: str = "wot") -> EventMetadata: """ Generates a `EventAffordance` TD fragment for this Event @@ -78,9 +85,9 @@ 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.from_descriptor(self, owner_inst or self.owner) + return MetadataFormats.get(format).event.from_descriptor(self, owner_inst or self.owner) class EventDispatcher: diff --git a/hololinked/core/interfaces/metadata.py b/hololinked/core/interfaces/metadata.py index 6629a7d3..4cef902b 100644 --- a/hololinked/core/interfaces/metadata.py +++ b/hololinked/core/interfaces/metadata.py @@ -102,7 +102,7 @@ class InteractionMetadata(BaseModel): need to extend this class. """ - _custom_schema_generators: ClassVar[dict] + _custom_metadata_generators: ClassVar[dict] def __init__(self): super().__init__() diff --git a/hololinked/core/property.py b/hololinked/core/property.py index 4127e8bf..5ec8df30 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_affordance(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.from_descriptor(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/thing.py b/hololinked/core/thing.py index 6fd56ec6..ea2a4deb 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -207,7 +207,7 @@ def sub_things(self) -> dict[str, "Thing"]: return things @action() - def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = []) -> Metadata: + 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. @@ -231,9 +231,13 @@ def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = [ # 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 hololinked.metadata.td.tm import ThingModel + from hololinked.ddl import MetadataFormats - return ThingModel(thing=self, ignore_errors=ignore_errors, skip_names=skip_names).generate() + return MetadataFormats.get(format).thing.generate( + thing=self, + ignore_errors=ignore_errors, + skip_names=skip_names, + ) thing_model = property(get_thing_model, doc=get_thing_model.__doc__) 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/metadata/td/interaction_affordance.py b/hololinked/metadata/td/interaction_affordance.py index 7c5bd135..41d0e4ca 100644 --- a/hololinked/metadata/td/interaction_affordance.py +++ b/hololinked/metadata/td/interaction_affordance.py @@ -45,7 +45,7 @@ class InteractionAffordance(WoTSchema, InteractionMetadata): forms: Optional[list[Form]] = None # uri variables - _custom_schema_generators: ClassVar = dict() + _custom_metadata_generators: ClassVar = dict() model_config = ConfigDict(extra="allow") @property @@ -243,7 +243,7 @@ def from_TD(self, name: str, TD: JSON) -> Self: def register_descriptor( cls, descriptor: Property | Action | Event, - schema_generator: type[InteractionAffordance] | type[InteractionMetadata], + metadata_generator: type[InteractionAffordance] | type[InteractionMetadata], ) -> None: """ Register a custom schema generator for a descriptor. @@ -252,7 +252,7 @@ def register_descriptor( ---------- descriptor: Property | Action | Event The descriptor class - schema_generator: `InteractionAffordance` + 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. @@ -266,12 +266,12 @@ def register_descriptor( 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 + InteractionAffordance._custom_metadata_generators[descriptor] = metadata_generator def __deepcopy__(self, memo): # noqa: D105 if self.__class__ == PropertyAffordance: @@ -323,7 +323,7 @@ def build(self) -> None: # noqa: D102 self.observable = property.observable @classmethod - def from_descriptor(cls, property: Property, owner: Thing | ThingMeta): # noqa: D102 + 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() 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)) From c3ade7dd464cbc3b5880aeabd0fb78a271d7583c Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Fri, 22 May 2026 18:22:28 +0200 Subject: [PATCH 09/11] import annotations --- hololinked/core/actions.py | 16 +++++++-- hololinked/core/dataklasses.py | 22 ++++++++---- hololinked/core/events.py | 48 +++++++++++++++++++-------- hololinked/core/state_machine.py | 23 +++++++------ hololinked/core/thing.py | 2 ++ hololinked/core/zmq/rpc_server.py | 4 +-- hololinked/server/http/controllers.py | 2 +- hololinked/server/http/server.py | 10 +++--- hololinked/server/http/services.py | 6 ++-- hololinked/server/mqtt/controllers.py | 2 +- hololinked/server/mqtt/server.py | 4 +-- hololinked/server/mqtt/services.py | 2 +- 12 files changed, 92 insertions(+), 49 deletions(-) diff --git a/hololinked/core/actions.py b/hololinked/core/actions.py index 8378a8a7..cd74a58e 100644 --- a/hololinked/core/actions.py +++ b/hololinked/core/actions.py @@ -1,3 +1,7 @@ +"""Concrete definition of an Action. Implemention of async and sync versions, action decorator.""" + +from __future__ import annotations + import warnings from enum import Enum @@ -113,7 +117,7 @@ def to_affordance(self, owner_inst: Thing | None = None, format: str = "wot") -> """ from hololinked.ddl import MetadataFormats - return MetadataFormats.generator_class(format).action.from_descriptor( + return MetadataFormats.get(format).action.from_descriptor( self, owner_inst or self.owner, ) @@ -141,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 @@ -154,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 @@ -162,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") 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 ea9ba7bd..043e9683 100644 --- a/hololinked/core/events.py +++ b/hololinked/core/events.py @@ -1,3 +1,7 @@ +"""Concrete definition of an Event.""" + +from __future__ import annotations + from typing import TYPE_CHECKING, Any, overload import jsonschema @@ -16,9 +20,10 @@ 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"] @@ -30,6 +35,8 @@ def __init__( label: str | None = None, ) -> None: """ + Initialize an event. + Parameters ---------- doc: str @@ -73,7 +80,7 @@ def __get__(self, obj: Parameterized, objtype: ParameterizedMetaclass = None): def to_affordance(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 ---------- @@ -92,8 +99,9 @@ def to_affordance(self, owner_inst: Thing | None = None, format: str = "wot") -> 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"] @@ -101,7 +109,7 @@ class EventDispatcher: def __init__( self, unique_identifier: str, - publisher: "EventPublisher", # noqa TODO fix + publisher: EventPublisher, owner_inst: ParameterizedMetaclass, descriptor: Event, ) -> None: @@ -111,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 @@ -129,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 @@ -141,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/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 ea2a4deb..c2e458fd 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -1,5 +1,7 @@ """Concrete Implementation of a `Thing` that represents a physical or virtual object.""" +from __future__ import annotations + import inspect import logging import ssl 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/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..27c64fd2 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 @@ -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) @@ -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: From 86bfcdf43be5f20e445578a52dc18dfbb5371645 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Fri, 22 May 2026 18:57:40 +0200 Subject: [PATCH 10/11] fix tests --- hololinked/core/thing.py | 12 ++++++++---- tests/test_10_thing_description.py | 7 ------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index c2e458fd..34c150b7 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -235,10 +235,14 @@ def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = [ # inaccurate but truthy value. In other words, schema validation will always pass. from hololinked.ddl import MetadataFormats - return MetadataFormats.get(format).thing.generate( - thing=self, - ignore_errors=ignore_errors, - skip_names=skip_names, + 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__) diff --git a/tests/test_10_thing_description.py b/tests/test_10_thing_description.py index 477a66c7..c650b410 100644 --- a/tests/test_10_thing_description.py +++ b/tests/test_10_thing_description.py @@ -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 From 172a1a43ff7c36dd95bc50835546cf15e0713169 Mon Sep 17 00:00:00 2001 From: Vignesh Venkatasubramanian Vaidyanathan <62492557+VigneshVSV@users.noreply.github.com> Date: Fri, 22 May 2026 19:42:44 +0200 Subject: [PATCH 11/11] ruff, ty and change signature of to_affordance to to_metadata --- hololinked/client/factory.py | 2 +- .../client/mqtt/consumed_interactions.py | 5 +- .../client/zmq/consumed_interactions.py | 4 +- hololinked/core/actions.py | 30 +++++++---- hololinked/core/events.py | 4 +- hololinked/core/interfaces/metadata.py | 16 ++++++ hololinked/core/meta.py | 2 +- hololinked/core/property.py | 2 +- hololinked/server/http/server.py | 12 ++--- hololinked/server/repository.py | 2 +- tests/test_06_actions.py | 16 +++--- tests/test_08_events.py | 2 +- tests/test_09_rpc_broker.py | 30 +++++------ tests/test_10_thing_description.py | 52 +++++++++---------- 14 files changed, 105 insertions(+), 74 deletions(-) diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py index a26709c4..574c7b40 100644 --- a/hololinked/client/factory.py +++ b/hololinked/client/factory.py @@ -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, diff --git a/hololinked/client/mqtt/consumed_interactions.py b/hololinked/client/mqtt/consumed_interactions.py index 8502b2c5..195920e6 100644 --- a/hololinked/client/mqtt/consumed_interactions.py +++ b/hololinked/client/mqtt/consumed_interactions.py @@ -13,7 +13,10 @@ from hololinked.client.abstractions import SSE, ConsumedThingEvent from hololinked.core.interfaces import BaseSerializer # noqa: F401 from hololinked.metadata.td.forms import Form -from hololinked.metadata.td.interaction_affordance import EventAffordance, PropertyAffordance +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 1e0bfe0a..eb0eb347 100644 --- a/hololinked/client/zmq/consumed_interactions.py +++ b/hololinked/client/zmq/consumed_interactions.py @@ -602,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, @@ -643,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 cd74a58e..191422d4 100644 --- a/hololinked/core/actions.py +++ b/hololinked/core/actions.py @@ -101,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: Thing | None = None, format: str = "wot") -> ActionMetadata: + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> ActionMetadata: """ Generates a `ActionAffordance` TD fragment for this Action. @@ -223,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. @@ -237,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) @@ -259,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) @@ -284,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 ---------- @@ -319,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/events.py b/hololinked/core/events.py index 043e9683..176c31c1 100644 --- a/hololinked/core/events.py +++ b/hololinked/core/events.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from hololinked.core.events import EventMetadata from hololinked.core.thing import Thing - from hololinked.core.zmq.brokers import EventPublisher # TODO remove this if possible + from hololinked.core.zmq.brokers import EventPublisher class Event: @@ -78,7 +78,7 @@ 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: Thing | None = None, format: str = "wot") -> EventMetadata: + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> EventMetadata: """ Generates a `EventAffordance` TD fragment for this Event. diff --git a/hololinked/core/interfaces/metadata.py b/hololinked/core/interfaces/metadata.py index 4cef902b..113cf1dd 100644 --- a/hololinked/core/interfaces/metadata.py +++ b/hololinked/core/interfaces/metadata.py @@ -276,6 +276,22 @@ def from_metadata(cls, name: str, metadata: dict[str, Any]) -> Self: """ 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, 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 5ec8df30..3407af8f 100644 --- a/hololinked/core/property.py +++ b/hololinked/core/property.py @@ -336,7 +336,7 @@ def observable(self) -> bool: """`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: Thing | None = None, format: str = "wot") -> PropertyMetadata: + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> PropertyMetadata: """ Generates a `PropertyAffordance` TD fragment for this Property. diff --git a/hololinked/server/http/server.py b/hololinked/server/http/server.py index 27c64fd2..423958e5 100644 --- a/hololinked/server/http/server.py +++ b/hololinked/server/http/server.py @@ -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( @@ -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: diff --git a/hololinked/server/repository.py b/hololinked/server/repository.py index b5741d8e..20c3244b 100644 --- a/hololinked/server/repository.py +++ b/hololinked/server/repository.py @@ -334,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/tests/test_06_actions.py b/tests/test_06_actions.py index 9fd7fa1d..d6115a8d 100644 --- a/tests/test_06_actions.py +++ b/tests/test_06_actions.py @@ -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 63e2c473..b273116c 100644 --- a/tests/test_08_events.py +++ b/tests/test_08_events.py @@ -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 aa3e5e70..c9da89dc 100644 --- a/tests/test_09_rpc_broker.py +++ b/tests/test_09_rpc_broker.py @@ -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(), @@ -619,7 +619,7 @@ def test_18_sync_client_event_stream( expected_data: Any, ): """Test if event can be streamed by a synchronous threaded client""" - resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance + resource = getattr(TestThing, event_name).to_metadata(thing) # type: EventAffordance form = Form() form.href = server.event_publisher.socket_address @@ -676,7 +676,7 @@ async def test_19_async_client_event_stream( 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 + 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 c650b410..1b95c42a 100644 --- a/tests/test_10_thing_description.py +++ b/tests/test_10_thing_description.py @@ -84,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" @@ -97,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] @@ -109,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): @@ -119,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 @@ -138,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( @@ -147,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 @@ -163,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 @@ -181,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( @@ -192,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) @@ -202,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 @@ -214,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: @@ -225,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] @@ -240,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( @@ -251,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 @@ -260,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 @@ -281,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") @@ -299,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 @@ -320,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" @@ -328,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) @@ -337,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"