diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 12339c096d1567895f24b154c76e222ad40107f0..283a70e6c24eccbbdd5ce2f6b4ec70dcb4f2f95e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -79,8 +79,8 @@ run_unit_tests_py37: # images with different python versions. stage: test before_script: - - apt-get update -y && DEBIAN_FRONTEND=noninteractive apt-get install -y libboost-python-dev libtango-dev # Needed to install pytango - - python -m pip install --upgrade pip + - apt-get update -y && DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip libboost-python-dev libtango-dev # Needed to install pytango + - python3 -m pip install --upgrade pip - pip install --upgrade tox run_unit_tests_py38: @@ -97,15 +97,12 @@ run_unit_tests_py39: - echo "run python3.9 unit tests /w coverage" - tox -e py39 -#run_unit_tests_py310: -# extends: .run_unit_tests_pyXX -# image: python:3.10-bullseye -# script: -# # Debian Bullseye ships with libboost-python linked to Python 3.9. Use the one from Debian Sid instead. -# - echo 'deb http://deb.debian.org/debian sid main' >> /etc/apt/sources.list -# - apt-get update -y && DEBIAN_FRONTEND=noninteractive apt-get install -y libboost-python1.74-dev -# - echo "run python3.10 unit tests /w coverage" -# - tox -e py310 +run_unit_tests_py310: + extends: .run_unit_tests_pyXX + image: ubuntu:jammy + script: + - echo "run python3.10 unit tests /w coverage" + - tox -e py310 run_unit_tests_py311: extends: .run_unit_tests_pyXX diff --git a/README.md b/README.md index d52dc03b8a4e4febc26c9606d15626f28736bb83..1cbc13ab1e0301e95c3d58febbac060f015f5677 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ -# Example Python Package +[](https://git.astron.nl/lofar2.0/attributewrapper/-/pipelines) +[](https://git.astron.nl/lofar2.0/attributewrapper/-/jobs/artifacts/main/download?job=coverage) +[](https://git.astron.nl/lofar2.0/attributewrapper) +[](https://git.astron.nl/lofar2.0/attributewrapper/-/jobs/artifacts/main/download?job=package_docs) +[](https://opensource.org/licenses/Apache-2.0) +[](https://git.astron.nl/lofar2.0/attributewrapper/-/releases) -An example repository of an CI/CD pipeline for building, testing and publishing a python package. +# PyTango Attribute Wrapper -If you find some missing functionality with regards to CI/CD, testing, linting or something else, feel free to make a merge request with the proposed changes. - - -## Example of README.md contents below: +The PyTango attribute wrapper reduces boilerplate code for pytango devices +by generating attribute read and write functions dynamically. ## Installation ``` @@ -13,11 +16,7 @@ pip install . ``` ## Usage -``` -from my_awesome_app import cool_module -cool_module.greeter() # prints "Hello World" -``` ## Contributing @@ -41,3 +40,7 @@ To automatically apply most suggested linting changes execute: ## License This project is licensed under the Apache License Version 2.0 + +## Releases + +- 0.1 - Initial release from separating into own repository \ No newline at end of file diff --git a/VERSION b/VERSION deleted file mode 100644 index 49d59571fbf6e077eece30f8c418b6aad15e20b0..0000000000000000000000000000000000000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1 diff --git a/attribute_wrapper/attribute_io.py b/attribute_wrapper/attribute_io.py new file mode 100644 index 0000000000000000000000000000000000000000..4e79b9497ac92b5b9eb637e9d6e1f94823a3824b --- /dev/null +++ b/attribute_wrapper/attribute_io.py @@ -0,0 +1,63 @@ +# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 + +import logging + +import numpy + +__all__ = ["AttributeIO"] + +logger = logging.getLogger() + + +class AttributeIO(object): + """Holds the I/O functionality for an attribute for a specific device.""" + + def __init__(self, device, attribute_wrapper): + # Link to the associated device + self.device = device + + # Link to the associated attribute wrapper + self.attribute_wrapper = attribute_wrapper + + # Link to last (written) value + self.cached_value = None + + # Specific read and write functions for this attribute on this device + self.read_function = lambda: None + self.write_function = lambda value: None + + def cached_read_function(self): + """Return the last (written) value, if available. Otherwise, read + from the device.""" + + if self.cached_value is not None: + return self.cached_value + + self.cached_value = self.read_function() + return self.cached_value + + def cached_write_function(self, value): + """Writes the given value to the device, and updates the cache.""" + + # flexible array sizes are not supported by all clients. make sure we only + # write arrays of maximum size. + if self.attribute_wrapper.shape != (): + if isinstance(value, numpy.ndarray): + value_shape = value.shape + else: + if len(value) > 0 and isinstance(value[0], list): + # nested list + value_shape = (len(value), len(value[0])) + else: + # straight list + value_shape = (len(value),) + + if value_shape != self.attribute_wrapper.shape: + raise ValueError( + f"Tried writing an array of shape {value_shape} into an attribute " + f"of shape {self.attribute_wrapper.shape}" + ) + + self.write_function(value) + self.cached_value = value diff --git a/attribute_wrapper/attribute_wrapper.py b/attribute_wrapper/attribute_wrapper.py index a1c6849cfbe08c1e565095ec05353eeaae8aaeef..8dcb6d9b9623afaf56c8e7b81e643813b345f9d0 100644 --- a/attribute_wrapper/attribute_wrapper.py +++ b/attribute_wrapper/attribute_wrapper.py @@ -4,88 +4,16 @@ import logging from functools import reduce from operator import mul -from functools import wraps -import numpy from tango import AttrWriteType, AttReqType from tango.server import attribute -logger = logging.getLogger() +from attribute_wrapper.attribute_io import AttributeIO +from attribute_wrapper.decorators import fault_on_error __all__ = ["AttributeWrapper"] - -def fault_on_error(): - """ - Wrapper to catch exceptions. Sets the device in a FAULT state if any occurs. - """ - - def inner(func): - @wraps(func) - def error_wrapper(device, *args, **kwargs): - try: - return func(device, *args, **kwargs) - except Exception as e: - logger.exception("Function failed.") - device.Fault(f"FAULT in {func.__name__}: {e.__class__.__name__}: {e}") - raise - - return error_wrapper - - return inner - - -class AttributeIO(object): - """Holds the I/O functionality for an attribute for a specific device.""" - - def __init__(self, device, attribute_wrapper): - # Link to the associated device - self.device = device - - # Link to the associated attribute wrapper - self.attribute_wrapper = attribute_wrapper - - # Link to last (written) value - self.cached_value = None - - # Specific read and write functions for this attribute on this device - self.read_function = lambda: None - self.write_function = lambda value: None - - def cached_read_function(self): - """Return the last (written) value, if available. Otherwise, read - from the device.""" - - if self.cached_value is not None: - return self.cached_value - - self.cached_value = self.read_function() - return self.cached_value - - def cached_write_function(self, value): - """Writes the given value to the device, and updates the cache.""" - - # flexible array sizes are not supported by all clients. make sure we only - # write arrays of maximum size. - if self.attribute_wrapper.shape != (): - if isinstance(value, numpy.ndarray): - value_shape = value.shape - else: - if len(value) > 0 and isinstance(value[0], list): - # nested list - value_shape = (len(value), len(value[0])) - else: - # straight list - value_shape = (len(value),) - - if value_shape != self.attribute_wrapper.shape: - raise ValueError( - f"Tried writing an array of shape {value_shape} into an attribute " - f"of shape {self.attribute_wrapper.shape}" - ) - - self.write_function(value) - self.cached_value = value +logger = logging.getLogger() class AttributeWrapper(attribute): diff --git a/attribute_wrapper/decorators.py b/attribute_wrapper/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..1fa4af729c9d5ca2e1df8dd056861f5e11ecc930 --- /dev/null +++ b/attribute_wrapper/decorators.py @@ -0,0 +1,27 @@ +# Copyright (C) 2022 ASTRON (Netherlands Institute for Radio Astronomy) +# SPDX-License-Identifier: Apache-2.0 + +import logging +from functools import wraps + +__all__ = ["fault_on_error"] + +logger = logging.getLogger() + + +def fault_on_error(): + """Wrapper to catch exceptions. Sets the device in a FAULT state if any occurs.""" + + def inner(func): + @wraps(func) + def error_wrapper(device, *args, **kwargs): + try: + return func(device, *args, **kwargs) + except Exception as e: + logger.exception("Function failed.") + device.Fault(f"FAULT in {func.__name__}: {e.__class__.__name__}: {e}") + raise + + return error_wrapper + + return inner diff --git a/docker/Dockerfile.ci_python37 b/docker/Dockerfile.ci_python37 index 29814542f7b942a44c0b6c28b944a2cab99011e4..c1caab8127c615af1b8862be65f38a50ac48d823 100644 --- a/docker/Dockerfile.ci_python37 +++ b/docker/Dockerfile.ci_python37 @@ -2,7 +2,7 @@ FROM python:3.7-buster # Install PyTango dependencies RUN apt-get update -y -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y libboost-python-dev libtango-dev +RUN DEBIAN_FRONTEND=noninteractive apt-get install -y git python3-pip libboost-python-dev libtango-dev # Make sure we have the latest tooling for our tests RUN python -m pip install --upgrade pip diff --git a/tests/test_client.py b/tests/example_client.py similarity index 97% rename from tests/test_client.py rename to tests/example_client.py index a5a7b6ac42ad5e4b2b03678085227bad1adbef90..df8488329cbaa1dd9b5c272940e58222b54c4627 100644 --- a/tests/test_client.py +++ b/tests/example_client.py @@ -9,10 +9,10 @@ import numpy logger = logging.getLogger() -class TestClient: +class ExampleClient: """Example comms_client implementation - During initialisation it creates a correctly shaped zero filled value. on read that + During initialisation, it creates a correctly shaped zero filled value. on read that value is returned and on write its modified. """ diff --git a/tests/test_attr_wrapper.py b/tests/test_attr_wrapper.py index 7e55be4e675f2e88253ace4aaa024ce979bb0ffd..d6eeadbc692f6d4e5debecdfd3f2b6ed022935a3 100644 --- a/tests/test_attr_wrapper.py +++ b/tests/test_attr_wrapper.py @@ -21,7 +21,7 @@ from tango.test_context import DeviceTestContext # Internal imports from attribute_wrapper.attribute_wrapper import AttributeWrapper from attribute_wrapper.states import INITIALISED_STATES -from test_client import TestClient +from example_client import ExampleClient SCALAR_DIMS = (1,) SPECTRUM_DIMS = (4,) @@ -32,14 +32,6 @@ STR_SPECTRUM_VAL = ["1", "1", "1", "1"] STR_IMAGE_VAL = [["1", "1"], ["1", "1"], ["1", "1"]] -# def dev_init(device): -# device.set_state(DevState.INIT) -# device.test_client = TestClient(device.Fault) -# for i in device.attr_list(): -# asyncio.run(i.async_set_comm_client(device, device.test_client)) -# device.test_client.start() - - class DeviceWrapper(Device, metaclass=DeviceMeta): def __init__(self, cl, name): super().__init__(cl, name) @@ -70,11 +62,11 @@ class DeviceWrapper(Device, metaclass=DeviceMeta): def configure_for_initialise(self): self.set_state(DevState.INIT) - self.test_client = TestClient(self.Fault) + self.example_client = ExampleClient(self.Fault) for i in self.attr_list(): - asyncio.run(i.async_set_comm_client(self, self.test_client)) - self.test_client.start() + asyncio.run(i.async_set_comm_client(self, self.example_client)) + self.example_client.start() @command() def initialise(self): @@ -82,12 +74,6 @@ class DeviceWrapper(Device, metaclass=DeviceMeta): class TestAttributeTypes(testscenarios.WithScenarios, unittest.TestCase): - # def setUp(self): - # # Avoid the device trying to access itself as a client - # self.deviceproxy_patch = mock.patch.object(DeviceWrapper, "DeviceProxy") - # self.deviceproxy_patch.start() - # self.addCleanup(self.deviceproxy_patch.stop) - class StrScalarDevice(DeviceWrapper): scalar_R = AttributeWrapper(comms_annotation="str_scalar_R", datatype=str) scalar_RW = AttributeWrapper( @@ -934,12 +920,6 @@ class TestAttributeTypes(testscenarios.WithScenarios, unittest.TestCase): class TestAttributeAccess(testscenarios.WithScenarios, TestCase): - # def setUp(self): - # # Avoid the device trying to access itself as a client - # self.deviceproxy_patch = mock.patch.object(DeviceWrapper, "DeviceProxy") - # self.deviceproxy_patch.start() - # self.addCleanup(self.deviceproxy_patch.stop) - class float32_scalar_device(DeviceWrapper): scalar_R = AttributeWrapper( comms_annotation="float32_scalar_R", datatype=numpy.float32 diff --git a/tox.ini b/tox.ini index ebd518ac527bde15d4a1e2ab65dc78a1c9b10f9f..621d91400e6e930470cbfd07d577ff1bde78c17f 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ commands = [testenv:coverage] commands = {envpython} --version - {envpython} -m pytest --cov-report xml --cov-report html --cov=map + {envpython} -m pytest --cov-report xml --cov-report html --cov-report xml:coverage.xml --cov=attribute_wrapper # Use generative name and command prefixes to reuse the same virtualenv # for all linting jobs.