Skip to content

Extending

Properties can also be extended to define custom types, validation and coercion based on specific requirements. As a contrived example, one may define a JPEG image attribute which may accept a numpy array as input, have a compression level setting, transpose and flip the image if necessary.

To create the property, inherit from the Property object and define the __init__:

Subclassing Property
from hololinked.server import Property

class JPEG(Property):
    """JPEG image data"""

    def __init__(self, default = None, 
                compression_ratio : int = 1, transpose : bool = False, 
                flip_horizontal : bool = False, flip_vertical : bool = False,
                **kwargs,
            ) -> None:
        super().__init__(default=default, allow_None=True, **kwargs)
        assert (isinstance(compression_ratio, int) and 
            compression_ratio >= 0 and compression_ratio <= 9
            ), "compression_ratio must be an integer between 0 and 9"
        self.compression_ratio = compression_ratio
        self.transpose = transpose
        self.flip_horizontal = flip_horizontal
        self.flip_vertical = flip_vertical

It is possible to use the __set__() to carry out type validation & coercion:

Validation with __set__()
import typing, numpy, imageio
from hololinked.server import Property
from hololinked.param.parameterized import instance_descriptor

class JPEG(Property):
    """JPEG image data"""

    @instance_descriptor
    def __set__(self, obj, value) -> None:
        if self.readonly:
            raise AttributeError("Cannot set read-only image attribute")
        if value is None and not self.allow_None:
            raise ValueError("None is not allowed")
        if isinstance(value, bytes):
            raise ValueError("Supply numpy.ndarray instead of pre-encoded JPEG image")
        if isinstance(value, numpy.ndarray):
            if self.flip_horizontal:
                value = numpy.fliplr(value)
            if self.flip_vertical:
                value = numpy.flipud(value)
            if self.transpose:
                value = numpy.transpose(value)
            return super().__set__(obj, 
                                imageio.imwrite('<bytes>', value, format='JPEG', 
                                    compress_level=self.compression_ratio)
                            )        
        raise ValueError(f"invalid type for JPEG image data - {type(value)}")

Basically, check the types, manipulate your data if necessary and pass it to the parent. It is necessary to use the instance_descriptor decorator as shown above to allow class_member option to function correctly. If the Property will not be a class_member, this decorator can be skipped.

Further, the parent class Property takes care of allocating an instance variable, checking constant, readonly, pushing change events, writing the value to the database etc. To avoid double checking of certain options like readonly and constant, its better to carry out the validation and coercion within the method validate_and_adapt() instead of __set__:

Validation and Adaption
class JPEG(Property):
    """JPEG image data"""
        self.flip_vertical = flip_vertical

    def validate_and_adapt(self, value) -> typing.Any:
        if value is None and not self.allow_None:
            # no need to check readonly & constant 
            raise ValueError("image attribute cannot take None")
        if isinstance(value, bytes):
            raise ValueError("Supply numpy.ndarray instead of pre-encoded JPEG image")
        if isinstance(value, numpy.ndarray):
            if self.flip_horizontal:
                value = numpy.fliplr(value)
            if self.flip_vertical:
                value = numpy.flipud(value)
            if self.transpose:
                value = numpy.transpose(value)
            return imageio.imwrite('<bytes>', value, format='JPEG', 
                                compress_level=self.compression_ratio)
        raise ValueError(f"invalid type for JPEG image data - {type(value)}")

The __set__() method automatically invokes validate_and_adapt(), therefore the new value or validated value can be returned from this method.

To use the JPEG property in a Thing class, follow the normal procedure of property instantiation:

Instantiating Custom Property
from hololinked.server import Thing

class Camera(Thing):

    _image = JPEG(doc="Image data in JPEG format", 
                compression_ratio=2, transpose=False, 
                flip_horizontal=True, remote=False) # type: bytes

    image = JPEG(readonly=True, doc="Image data in JPEG format",
                fget=lambda self: self._image) # type: bytes

    def capture(self):
        while True:
            # write image capture logic here
            self._image = image # captured image

In this particular example, since we dont want the JPEG to be set externally by a client, we create a local Property which carries out the image manipulation and an externally visible readonly Property that can fetch the processed image.

The difference between using a custom setter/fset method and overloading the Property is that, one can accept certain options specific to the Property in the __init__ of the Property:

Reusing Custom Property
class Camera(Thing):

    horizontally_flipped_image = JPEG(doc="Image data in JPEG format",
                                    flip_horizontal=True) # type: bytes

    vertically_flipped_image = JPEG(doc="Image data in JPEG format", 
                                flip_vertical=True) # type: bytes

    transposed_image = JPEG(doc="Image data in JPEG format", 
                            flip_horizontal=False, flip_vertical=False,
                            transpose=True) # type: bytes

Note

This is a contrived example and may not lead to optimized Property APIs for the client. Please adapt them suitably or rethink their implementation.

One may also use slots to store the attributes of the Property. Most properties predefined in this package use slots:

Using slots
class JPEG(Property):
    """JPEG image data"""

    __slots__ = ['compression_ratio', 'transpose', 'flip_horizontal', 'flip_vertical']

    def __init__(self, default = None, 
                compression_ratio : int = 1, transpose : bool = False, 
                flip_horizontal : bool = False, flip_vertical : bool = False,
                **kwargs,
            ) -> None:
        # ... super().__init__ ...
        self.compression_ratio = compression_ratio
        self.transpose = transpose
        self.flip_horizontal = flip_horizontal
        self.flip_vertical = flip_vertical