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.core import Property, Thing


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

    def __init__(
        self,
        default: Optional[numpy.ndarray] = 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)
        if (
            not isinstance(compression_ratio, int)
            or not 0 <= compression_ratio <= 9
        ):
            raise ValueError("compression_ratio must be an integer from 0 to 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. This method is automatically invoked by python:

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


class JPEG(Property):
    """JPEG image data"""
        raise ValueError(f"invalid type for JPEG image data - {type(value)}")

    @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)
            binary = (
                imageio.imwrite(
                    "<bytes>",
                    value,
                    format="JPEG",
                    compress_level=self.compression_ratio,
                ),
            )
            return super().__set__(obj, binary)
        raise ValueError(f"invalid type for JPEG image data - {type(value)}")

Essentially:

  • check metadata options like readonly, constant etc.
  • check the type of the input value
  • manipulate your data if necessary
  • 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 etc. Therefore, to avoid redundancy, its recommended to implement a validate_and_adapt() method instead of __set__:

Validation and Adaption
class JPEG(Property):
    """JPEG image data"""

    def validate_and_adapt(self, value) -> bytes:
        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(), and a return value is expected.

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

Instantiating Custom Property
class Camera(Thing):
    """Example object with custom defined JPEG property"""

    _image = JPEG(
        doc="Image data in JPEG format, not exposed to client, used internally",
        compression_ratio=2,
        transpose=False,
        flip_horizontal=True,
        remote=False,
    )  # type: bytes

    image = JPEG(
        readonly=True,  # dont allow clients to manipulate camera's image
        doc="latest captured image data in JPEG format",
        fget=lambda self: self._image,
    )  # type: bytes

    def capture(self):
        while True:
            image = self._capture_image()  # 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 supply the processed image to the client.

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):
    """Example object with custom defined JPEG property"""

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

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

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

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