diff --git a/README.md b/README.md index efe36bfbf01b0c4675c0eebb7365a44de2f281ae..9e610619411f528ecbc92eba5100b60a52f17f07 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [](https://git-ce.rwth-aachen.de/wzl-mq-ms/forschung-lehre/lava/unified-device-interface/python/commits/master) # Python Unified Device Interface -Current stable version: 9.2.0 +Current stable version: 9.3.0 ## Installation 1. Install the WZL-UDI package via pip @@ -58,6 +58,11 @@ Funded by the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) ## Recent changes +**9.3.0** - 2024-03-13 + - implemented semantic features for functions + - refactoring + - renamed class Figure to Variable to reflect updated SOIL meta model + **9.2.0** - 2024-02-08 - functions can be used to publish results via MQTT instead returning the results as response to the POST request - if a function is implemented as generator this behaviour is triggered automatically diff --git a/setup.py b/setup.py index 5441d0f88521051b31c87dbe66cf46ac5100f57e..64733456bb6e0e707b5baf49626280898671ea75 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() setup(name='wzl-udi', - version='9.2.0', + version='9.3.0', url='https://git-ce.rwth-aachen.de/wzl-mq-public/soil/python', project_urls={ "Bug Tracker": "https://git-ce.rwth-aachen.de/wzl-mq-public/soil/python/-/issues", diff --git a/src/http/server.py b/src/http/server.py index 35780cb623e682fe7263111fae73d91806fe97b8..089f3590508d2d70c767653e5b2d8ba4ea82ff61 100644 --- a/src/http/server.py +++ b/src/http/server.py @@ -16,7 +16,7 @@ from .error import ServerException from ..soil.component import Component from ..soil.element import Element from ..soil.error import InvokationException, ReadOnlyException, ChildNotFoundException -from ..soil.figure import Figure +from ..soil.variable import Variable from ..soil.function import Function from ..soil.measurement import Measurement from ..soil.parameter import Parameter @@ -295,7 +295,7 @@ class HTTPServer(object): logger.error('Response: {}'.format(response)) return self.prepare_response(response, None, status=404, query=request.query) - if not isinstance(item, Figure): + if not isinstance(item, Variable): return self.prepare_response({}, None, status=405, query=request.query) response = item.serialize(keys, self._legacy_mode, HTTP_OPTIONS) diff --git a/src/soil/component.py b/src/soil/component.py index 0ae5d4d023967b00bb6da72ca747d1e3bb51a18b..1df9efc90cc869942c46b0ba811f95c85afea2de 100644 --- a/src/soil/component.py +++ b/src/soil/component.py @@ -395,7 +395,7 @@ class Component(Element): result = self._metadata else: try: - shape_filename = os.path.join(self._profile_path, f'{kind.replace("Shape", "")}.shacl.ttl') + shape_filename = os.path.join(self._profile_path, f'{kind.replace("Profile", "")}.shacl.ttl') result = rdflib.Graph() result.parse(shape_filename) result.add((rdflib.URIRef(Semantics.namespace[kind]), Namespaces.dcterms.license, @@ -404,7 +404,7 @@ class Component(Element): raise DeviceException('The provided kind of semantic information cannot be returned.') if recursive: - for child in self._components + self._measurements + self._parameters: + for child in self.children: result += child.serialize_semantics(kind, recursive) return result diff --git a/src/soil/element.py b/src/soil/element.py index d2a45a8958190c6304fb7827ebe364710e1c1348..f3e8b95bc6ab7fce63065c258483376b21116c2c 100644 --- a/src/soil/element.py +++ b/src/soil/element.py @@ -102,7 +102,7 @@ class Element(ABC): shape_filename = os.path.join(profiles_path, f"{self._profilename}.shacl.ttl") self._metadata_profile = rdflib.Graph() self._metadata_profile.parse(shape_filename) - self._metadata_profile.add((rdflib.URIRef(Semantics.namespace[f'{self._profilename}Shape']), Namespaces.dcterms.license, + self._metadata_profile.add((rdflib.URIRef(Semantics.namespace[f'{self._profilename}Profile']), Namespaces.dcterms.license, Semantics.profile_license)) # load metadata @@ -117,7 +117,7 @@ class Element(ABC): ... def resolve_semantic_path(self, suffix: str) -> ('Element', str): - if suffix == f'{self._profilename}Shape': + if suffix == f'{self._profilename}Profile': return self, 'profile' elif suffix == self.semantic_name.split('/')[-1]: return self, 'metadata' diff --git a/src/soil/function.py b/src/soil/function.py index 1676f523f447e125acdfc23f72cb099982685bfe..aaafc792a86d194a1e80503e3acc190ab4d70c9c 100644 --- a/src/soil/function.py +++ b/src/soil/function.py @@ -7,7 +7,8 @@ import rdflib from .element import Element from .error import InvokationException, NotImplementedException, ChildNotFoundException -from .figure import Figure, serialize_time +from .semantics import Namespaces +from .variable import Variable, serialize_time from .parameter import Parameter from ..utils import root_logger from ..utils.constants import HTTP_GET, HTTP_OPTIONS @@ -18,7 +19,7 @@ logger = root_logger.get(__name__) class Function(Element): - def __init__(self, uuid: str, name: str, description: str, arguments: List[Figure], returns: List[Figure], + def __init__(self, uuid: str, name: str, description: str, arguments: List[Variable], returns: List[Variable], implementation: Callable, ontology: str = None, profile: str = None): Element.__init__(self, uuid, name, description, ontology, profile) if uuid[:3] != 'FUN': @@ -72,7 +73,7 @@ class Function(Element): '{}: The number of given arguments does not match the number of required arguments!'.format( self.uuid)) for v in value: - if not isinstance(v, Figure): + if not isinstance(v, Variable): raise Exception('{}: Given argument is not of type Figure!'.format(self.uuid)) self._arguments = value elif key == "returns": @@ -82,7 +83,7 @@ class Function(Element): raise Exception( '{}: The number of given returns does not match the number of required returns!'.format(self.uuid)) for v in value: - if not isinstance(v, Figure): + if not isinstance(v, Variable): raise Exception('{}: Given return is not of type Figure!'.format(self.uuid)) self._returns = value else: @@ -107,14 +108,14 @@ class Function(Element): uuid)) else: var = var[0] - Figure.check_all(var.datatype, var.dimension, var.range, value) + Variable.check_all(var.datatype, var.dimension, var.range, value) ret = self.__getitem__([uuid]).serialize([], legacy_mode, HTTP_OPTIONS) ret['value'] = value ret['timestamp'] = serialize_time(datetime.datetime.now()) returns['returns'] += [ret] return returns - async def invoke_generator(self, arguments: List[Figure], legacy_mode: bool = False) -> Dict[str, List[Dict[str, Any]]]: + async def invoke_generator(self, arguments: List[Variable], legacy_mode: bool = False) -> Dict[str, List[Dict[str, Any]]]: returns = {"returns": []} args = {} if self._implementation is None: @@ -122,7 +123,7 @@ class Function(Element): for a in arguments: var = self.__getitem__([a["uuid"]]) - Figure.check_all(var.datatype, var.dimension, var.range, a["value"]) + Variable.check_all(var.datatype, var.dimension, var.range, a["value"]) args[self._signature['arguments'][a["uuid"]]] = a["value"] try: @@ -149,14 +150,14 @@ class Function(Element): raise DeviceException(str(e), predecessor=e) - async def invoke(self, arguments: List[Figure], legacy_mode: bool = False) -> Dict[str, List[Dict[str, Any]]]: + async def invoke(self, arguments: List[Variable], legacy_mode: bool = False) -> Dict[str, List[Dict[str, Any]]]: args = {} if self._implementation is None: raise NotImplementedException(self._uuid, self._name) for a in arguments: var = self.__getitem__([a["uuid"]]) - Figure.check_all(var.datatype, var.dimension, var.range, a["value"]) + Variable.check_all(var.datatype, var.dimension, var.range, a["value"]) args[self._signature['arguments'][a["uuid"]]] = a["value"] # set up servers @@ -172,17 +173,17 @@ class Function(Element): def serialize(self, keys: List[str], legacy_mode: bool, method: int = HTTP_GET) -> Dict[str, Any]: if not keys or 'all' in keys: - keys = ['uuid', 'name', 'description', 'arguments', 'returns', 'ontology'] + keys = ['uuid', 'name', 'description', 'arguments', 'returns', 'ontology', 'profile'] dictionary = super().serialize(keys, legacy_mode) if 'arguments' in keys: dictionary['arguments'] = list( map(lambda x: x.serialize( - ['name', 'uuid', 'description', 'datatype', 'value', 'dimension', 'range', 'ontology'], legacy_mode, + ['name', 'uuid', 'description', 'datatype', 'value', 'dimension', 'range', 'ontology', 'profile'], legacy_mode, HTTP_OPTIONS), self._arguments)) if 'returns' in keys: dictionary['returns'] = list( - map(lambda x: x.serialize(['name', 'uuid', 'description', 'datatype', 'dimension', 'ontology'], + map(lambda x: x.serialize(['name', 'uuid', 'description', 'datatype', 'dimension', 'ontology', 'profile'], legacy_mode, HTTP_OPTIONS), self._returns)) return dictionary @@ -228,17 +229,45 @@ class Function(Element): raise SerialisationException('{}: The function can not be deserialized. {}'.format(uuid, e)) def load_semantics(self, profiles_path: str, metadata_path: str, parent_name: str) -> None: - # This method does nothing intentionally, as we do not have any semantic definition for function - pass + super().load_semantics(profiles_path, metadata_path, parent_name) + + for child in self._arguments + self._returns: + child.load_semantics(profiles_path, metadata_path, f"{parent_name}{self.uuid[4:].capitalize()}") def serialize_semantics(self, kind: str, recursive=False) -> rdflib.Graph: - # This method does nothing intentionally, as we do not have any semantic definition for function - return None + if self._metadata_profile is None or self._metadata is None: + raise SerialisationException('No semantic information have been provided during initialization.') + + if kind == 'profile': + result = self._metadata_profile + elif kind == 'metadata': + result = self._metadata + else: + raise DeviceException('The provided kind of semantic information cannot be returned.') + + if recursive: + for child in self._arguments + self._returns: + result += child.serialize_semantics(kind, recursive) + + return result def resolve_semantic_path(self, suffix: str) -> (Element, str): - # This method does nothing intentionally, as we do not have any semantic definition for function - raise ChildNotFoundException('Could not resolve the semantic path.') + try: + return super().resolve_semantic_path(suffix) + except ChildNotFoundException: + # check if the path fits one of the components children + for child in self._arguments + self._returns: + try: + return child.resolve_semantic_path(suffix) + except ChildNotFoundException: + continue + + raise ChildNotFoundException('Could not resolve the semantic path.') @property def semantic_name(self) -> str: - return "" + if self._metadata is None: + return "" + subject = next( + self._metadata.subjects(predicate=Namespaces.rdf.type, object=Namespaces.sosa.Procedure)) + return subject.toPython() diff --git a/src/soil/measurement.py b/src/soil/measurement.py index 6f30a7554ebbc9664ca8d7d55a4782570290fd2b..f2b7a3f6d9e21518b1f7109a76b3fcc7cb4fc549 100644 --- a/src/soil/measurement.py +++ b/src/soil/measurement.py @@ -7,7 +7,7 @@ import rdflib from deprecated import deprecated from .datatype import Datatype -from .figure import Figure +from .variable import Variable from .semantics import Semantics, Namespaces from ..utils import root_logger from ..utils.constants import HTTP_GET @@ -16,11 +16,11 @@ from ..utils.error import SerialisationException, DeviceException logger = root_logger.get(__name__) -class Measurement(Figure): +class Measurement(Variable): def __init__(self, uuid: str, name: str, description: str, datatype: Datatype, dimension: List[int], range: List, getter: Callable, unit: str, label=None, ontology: str = None, profile: str = None): - Figure.__init__(self, uuid, name, description, datatype, dimension, range, None, getter, ontology, profile) + Variable.__init__(self, uuid, name, description, datatype, dimension, range, None, getter, ontology, profile) if uuid[:3] != 'MEA': raise Exception('{}: The UUID must start with MEA!'.format(uuid)) self._unit = unit @@ -218,7 +218,7 @@ class Measurement(Figure): data_graph.add((measurement_subject, Namespaces.qudt.unit, unit_triples[0][2])) data_graph.add((measurement_subject, Namespaces.schema.license, Semantics.data_license)) - rdf_value = Figure.serialize_value(data_graph, self.__getitem__('value', 0)) + rdf_value = Variable.serialize_value(data_graph, self.__getitem__('value', 0)) data_graph.add((measurement_subject, Namespaces.qudt.value, rdf_value)) data_graph.add((measurement_subject, Namespaces.schema.dateCreated, diff --git a/src/soil/parameter.py b/src/soil/parameter.py index b70da8cad7c6b5e1633f7eaa81a9f63015a3e797..f7e862b6972d8c5856e157a34dbc5cc7639f9906 100644 --- a/src/soil/parameter.py +++ b/src/soil/parameter.py @@ -7,7 +7,7 @@ import rdflib from .datatype import Datatype from .error import ReadOnlyException -from .figure import Figure +from .variable import Variable from .semantics import Semantics, Namespaces from ..utils import root_logger from ..utils.constants import HTTP_GET @@ -16,12 +16,12 @@ from ..utils.error import DeviceException, SerialisationException logger = root_logger.get(__name__) -class Parameter(Figure): +class Parameter(Variable): def __init__(self, uuid: str, name: str, description: str, datatype: Datatype, dimension: List[int], range: List, value: Any, getter: Callable = None, setter: Callable = None, ontology: str = None, profile: str = None): - Figure.__init__(self, uuid, name, description, datatype, dimension, range, value, getter, ontology, profile) + Variable.__init__(self, uuid, name, description, datatype, dimension, range, value, getter, ontology, profile) if uuid[:3] not in ['PAR', 'ARG', 'RET']: raise Exception('{}: The UUID must start with PAR, ARG or RET!'.format(uuid)) if setter is not None and not callable(setter): @@ -36,7 +36,7 @@ class Parameter(Figure): :param value: value to be set """ if key == "value": - Figure.check_all(self._datatype, self._dimension, self._range, value) + Variable.check_all(self._datatype, self._dimension, self._range, value) # self._timestamp, value = self._implementation() try: if inspect.iscoroutinefunction(self.set): @@ -154,7 +154,7 @@ class Parameter(Figure): assert (len(triples) == 1) result.remove(triples[0]) - rdf_value = Figure.serialize_value(result, self.__getitem__('value', 0)) + rdf_value = Variable.serialize_value(result, self.__getitem__('value', 0)) result.add((Semantics.namespace[self._semantic_name], Namespaces.qudt['value'], rdf_value)) return result else: @@ -165,5 +165,11 @@ class Parameter(Figure): def semantic_name(self) -> str: if self._metadata is None: return "" - subject = next(self._metadata.subjects(predicate=Namespaces.rdf.type, object=Namespaces.ssn.Property)) + if self.uuid[:3] == 'PAR': + subject = next(self._metadata.subjects(predicate=Namespaces.rdf.type, object=Namespaces.ssn.Property)) + elif self.uuid[:3] == 'ARG': + subject = next(self._metadata.subjects(predicate=Namespaces.rdf.type, object=Namespaces.ssn.Input)) + else: + assert self.uuid[:3] == 'RET' + subject = next(self._metadata.subjects(predicate=Namespaces.rdf.type, object=Namespaces.ssn.Output)) return subject.toPython() diff --git a/src/soil/stream.py b/src/soil/stream.py index 0330d8634222c3f39aded671cef7ad53473fe360..1aba0ee20d36ba6c4539332061a552fed4f78484 100644 --- a/src/soil/stream.py +++ b/src/soil/stream.py @@ -7,10 +7,9 @@ import rdflib from wzl.mqtt.client import MQTTPublisher from wzl.mqtt.exceptions import ClientNotFoundError -from . import figure from .component import Component from .event import Event -from .figure import Figure +from .variable import Variable from .semantics import Namespaces from ..utils import root_logger from ..utils import serialize @@ -128,7 +127,7 @@ class Job(ABC): # replace value data.remove((None, Namespaces.qudt.value, None)) - data.add((measurement_subject, Namespaces.qudt.value, Figure.serialize_value(data, self.value))) + data.add((measurement_subject, Namespaces.qudt.value, Variable.serialize_value(data, self.value))) # replace timestamp data.remove((None, Namespaces.schema.dateCreated, None)) diff --git a/src/soil/figure.py b/src/soil/variable.py similarity index 94% rename from src/soil/figure.py rename to src/soil/variable.py index f833f03631f09ac7011a868b2bdb52f00ed95156..3f977369a31668263c3dbb0be27a525c534294ba 100644 --- a/src/soil/figure.py +++ b/src/soil/variable.py @@ -39,13 +39,13 @@ def serialize_time(time): return rfc3339.timestamp_to_rfc3339_utcoffset(timestamp) -class Figure(Element, ABC): +class Variable(Element, ABC): def __init__(self, uuid: str, name: str, description: str, datatype: Datatype, dimension: List[int], range: List, value: Any, getter: Callable, ontology: str = None, profile: str = None): Element.__init__(self, uuid, name, description, ontology, profile) # if type(datatype) is not str: # raise Exception('{}: Datatype must be passed as string.'.format(uuid)) - Figure.check_all(datatype, dimension, range, value) + Variable.check_all(datatype, dimension, range, value) if getter is not None and not callable(getter): raise TypeError("{}: The getter of the Figure must be callable!".format(uuid)) self._datatype = datatype @@ -98,7 +98,7 @@ class Figure(Element, ABC): 'Could not provide value of Measurement/Parameter {}: {}'.format(self.uuid, str(e)), predecessor=e) - Figure.check_all(self._datatype, self._dimension, self._range, value) + Variable.check_all(self._datatype, self._dimension, self._range, value) self._value = value return value else: @@ -152,7 +152,7 @@ class Figure(Element, ABC): if value is None: return # base case 1: dimension is empty and variable is not a scalar => not valid - if not dimension and not Figure.is_scalar(value): + if not dimension and not Variable.is_scalar(value): raise DimensionException('Figure of dimension 0 can not be of type list!') # base case 2: dimension is empty and variable is a scalar => valid elif not dimension: @@ -168,7 +168,7 @@ class Figure(Element, ABC): # => recursively check the dimension of each "subvalue" for v in value: try: - Figure.check_dimension(dimension[1:], v) + Variable.check_dimension(dimension[1:], v) except DimensionException as e: raise e @@ -183,7 +183,7 @@ class Figure(Element, ABC): if value is None: return # base case: value is a scalar - if Figure.is_scalar(value): + if Variable.is_scalar(value): # check if the type of value corresponds to given datatype if datatype == Datatype.BOOLEAN and not isinstance(value, bool): raise TypeException("Boolean field does not match non-boolean value {}!".format(value)) @@ -207,7 +207,7 @@ class Figure(Element, ABC): # recursion case: value is an array or matrix => check datatype of each "subvalue" recursively for v in value: try: - Figure.check_type(datatype, v) + Variable.check_type(datatype, v) except TypeException as e: raise e @@ -237,7 +237,7 @@ class Figure(Element, ABC): else: return # base case: value is scalar => check if the value is in range - if Figure.is_scalar(value): + if Variable.is_scalar(value): # bool is not checked, since there is only true and false if datatype == Datatype.BOOLEAN: return @@ -284,7 +284,7 @@ class Figure(Element, ABC): # recursion case: value is an array or matrix => check range of each "subvalue" recursively for v in value: try: - Figure.check_range(datatype, range, v) + Variable.check_range(datatype, range, v) except RangeException as e: raise e @@ -294,11 +294,11 @@ class Figure(Element, ABC): blank_node = rdflib.BNode() data_graph.add((blank_node, Namespaces.rdf.rest, Namespaces.rdf.nil)) data_graph.add( - (blank_node, Namespaces.rdf.first, Figure.serialize_value(data_graph, value[len(value) - 1]))) + (blank_node, Namespaces.rdf.first, Variable.serialize_value(data_graph, value[len(value) - 1]))) for entry in reversed(value[:-1]): new_blank_node = rdflib.BNode() data_graph.add((new_blank_node, Namespaces.rdf.rest, blank_node)) - data_graph.add((new_blank_node, Namespaces.rdf.first, Figure.serialize_value(data_graph, entry))) + data_graph.add((new_blank_node, Namespaces.rdf.first, Variable.serialize_value(data_graph, entry))) blank_node = new_blank_node return blank_node else: @@ -310,9 +310,9 @@ class Figure(Element, ABC): @staticmethod def check_all(datatype, dimension, range, value): - Figure.check_type(datatype, value) - Figure.check_dimension(dimension, value) - Figure.check_range(datatype, range, value) + Variable.check_type(datatype, value) + Variable.check_dimension(dimension, value) + Variable.check_range(datatype, range, value) @property def get(self): diff --git a/test/_figure_checks_test.py b/test/_figure_checks_test.py index 25b8136a23f564e4b100f3de75d7a149d96296d4..a9e864307dcfb9236f21202628fff81735916bc2 100644 --- a/test/_figure_checks_test.py +++ b/test/_figure_checks_test.py @@ -7,7 +7,7 @@ repo_path = str(pathlib.Path(__file__).parent) if repo_path not in sys.path: sys.path.insert(0, repo_path) -from src.soil.figure import Figure +from src.soil.figure import Variable from src.soil.error import DimensionException, TypeException @@ -42,7 +42,7 @@ class TestFigure(object): @pytest.mark.parametrize('number', [i for i in range(15)]) @pytest.mark.parametrize('test', ['scalar']) def test_is_scalar(self, number, test, parametrized_value, expected_result): - result = Figure.is_scalar(parametrized_value) + result = Variable.is_scalar(parametrized_value) assert result == expected_result @pytest.mark.parametrize('number', [i for i in range(15)]) @@ -50,22 +50,22 @@ class TestFigure(object): def test_check_dimension(self, number, test, parametrized_value, parametrized_shape, expected_result): if expected_result: try: - Figure.check_dimension(parametrized_shape, parametrized_value) + Variable.check_dimension(parametrized_shape, parametrized_value) except DimensionException as e: assert False, e else: with pytest.raises(DimensionException): - Figure.check_dimension(parametrized_shape, parametrized_value) + Variable.check_dimension(parametrized_shape, parametrized_value) @pytest.mark.parametrize('number', [i for i in range(15)]) @pytest.mark.parametrize('test', ['datatype']) def test_check_datatype(self, number, test, parametrized_value, parametrized_shape, expected_result): if expected_result: try: - Figure.check_type(parametrized_shape, parametrized_value) + Variable.check_type(parametrized_shape, parametrized_value) except TypeException as e: assert False, e else: with pytest.raises(TypeException): - Figure.check_type(parametrized_shape, parametrized_value) + Variable.check_type(parametrized_shape, parametrized_value)