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
+[![Pipeline Status](https://git.astron.nl/lofar2.0/attributewrapper/badges/main/pipeline.svg)](https://git.astron.nl/lofar2.0/attributewrapper/-/pipelines)
+[![Coverage Status](https://git.astron.nl/lofar2.0/attributewrapper/badges/main/coverage.svg)](https://git.astron.nl/lofar2.0/attributewrapper/-/jobs/artifacts/main/download?job=coverage)
+[![Python Versions](https://img.shields.io/badge/python-3.7%20|%203.8%20|%203.9%20|%203.10%20|%203.11-informational)](https://git.astron.nl/lofar2.0/attributewrapper)
+[![Latest Documentation](https://img.shields.io/badge/docs-download-informational)](https://git.astron.nl/lofar2.0/attributewrapper/-/jobs/artifacts/main/download?job=package_docs)
+[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
+[![Latest Release](https://git.astron.nl/lofar2.0/attributewrapper/-/badges/release.svg)](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.