Features

Features implement functionality for a Device class. For example, if you have a device that can send messages, this device needs a Feature class that implements the functionality to send those messages.

A Device itself never implements this functionality. Instead, a Device acts only as a container that holds various Feature classes.

The initial definition of a feature is typically done within the Scenario class, specifically in a Scenario-Devices. As a reminder, a scenario describes the interface that must be implemented at minimum in the :ref:`Setup-Devices <Setup-Device>`, where the actual implementation is provided.

To make this easier to understand, let’s examine the following example:

# file `lib/scenario_features/sender_feature.py`
class SenderFeature(balder.Feature):

    def send(msg):
        raise NotImplementedError("this feature has to be implemented by subclass")
# file `scenarios/scenario_my.py`

from lib.scenario_features.sender_feature import SenderFeature

class MyScenario(balder.Scenario):

    class SenderDevice(balder.Device):

        sender = SenderFeature()

    def test_send_something():
        self.SenderDevice.sender.send("Hello World!")

So far, we have defined all the interfaces we need in our test test_send_something, but we still lack the actual implementation for them. So, let’s take a look at the setup implementation. To do this, we’ll add a new file and implement the feature for our setup there:

# file `lib/setup_features/pipe_sender_feature.py`
from lib.scenario_features.sender_feature import SenderFeature

class PipeSenderFeature(SenderFeature):
    ...
    def send(msg):
        self.pipe.send(msg)

This feature class is no longer abstract. It implements all the abstract methods (in our case, the method send()). We can now use this feature in our setup class:

# file `setups/setup_my.py`
from lib.setup_features.pipe_sender_feature import PipeSenderFeature

class SetupMy(balder.Setup):

    class PipeDevice(balder.Device):
        pipe_sender = PipeSenderFeature()

As you can see you have to implement the feature and instantiate it in the same way, like you have instantiated it in the scenario class.

Inner-Feature-Referencing

Sometimes it is necessary to refer to another feature from within a feature. For example, in the real-world example from above, you might need access to the pipe. This can easily be achieved using inner-feature referencing.

Imagine we have another feature called ManagePipeFeature, which provides some methods to interact with the pipe. We can use it within PipeSenderFeature like that:

# file `lib/setup_features/pipe_sender_feature.py`
from lib.scenario_features.sender_feature import SenderFeature

class PipeSenderFeature(SenderFeature):

    pipe = ManagePipeFeature()

    def send(msg):
        self.pipe.send(msg)

You simply have to instantiate it as class attribute inside your feature. This will automatically lead to the behavior, that Balder assumes that your feature only works with a device, that also provides an implementation for the ManagePipeFeature. The reference inside your feature will automatically provided by Balder on variation-level.

You simply need to instantiate it as a class attribute inside the feature, that wants to use it. This will automatically result in Balder assuming that your feature only works with a device that also provides an implementation for the ManagePipeFeature.

So this means, that we need to add the ManagePipeFeature to our setup like that:

# file `setups/setup_my.py`
from lib.setup_features.manage_pipe_feature import ManagePipeFeature
from lib.setup_features.pipe_sender_feature import PipeSenderFeature

class SetupMy(balder.Scenario):

    class PipeDevice(balder.Device):
        pipe_manager = ManagePipeFeature()
        pipe_sender = PipeSenderFeature()

Note

You don’t need to make any changes in the scenario, as the scenario isn’t concerned with how you provide the implementation. This detail is only relevant to the specific setup implementation - in this case, via the ManagePipeFeature.

Autonomous-Features

Autonomous features are a special type of Feature class. These features have no methods or properties of their own; they are only used to indicate that: This device has the feature IsRedFeature.

We use them for filtering because we only want to match with a setup device that provides the same feature.

Defining such an autonomous feature is really straightforward:

# file `lib/scenario_features/pipe_mirror_autonomous_feature.py`

class PipeMirrorAutonomousFeature(balder.Feature):
    # an autonomous feature has no implementation
    pass

An autonomous feature behaves the same way as a normal Feature. Since you can’t interact with it directly, we highly recommend assigning it to a property name that starts with an underscore:

# file `scenarios/scenario_my.py`
import balder

from lib.scenario_features.pipe_mirror_autonomous_feature import PipeMirrorAutonomousFeature
from lib.scenario_features.sender_feature import SenderFeature

class MyScenario(balder.Scenario):

    class SenderDevice(balder.Device):
        sender = SenderFeature()

    @balder.connect(SenderDevice, over_connection=balder.Connection)
    class ReceiverDevice(balder.Device):
        _pipe_mirror = PipeMirrorAutonomousFeature()  # the autonomous feature

This is a good real-world example. Imagine you want to test whether the ReceiverDevice can mirror a message that you’ve sent using another SenderDevice. In this case, we can only influence the SenderDevice, but we have no way to interact with the ReceiverDevice. We only know that this device can mirror the messages we’ve sent.

As we have done here, we can use an autonomous feature for our ReceiverDevice, because we know the device must have this capability, but we cannot influence it.

Your setup can use the same object. You don’t need to override it, since you don’t want to add any functionality to it. So, we simply reuse this feature from the scenario level in our setup:

# file `setups/setup_my.py`
import balder

from lib.scenario_features.pipe_mirror_autonomous_feature import PipeMirrorAutonomousFeature
from lib.setup_features.manage_pipe_feature import ManagePipeFeature
from lib.setup_features.pipe_sender_feature import PipeSenderFeature


class SetupMy(balder.Setup):

    class PipeDevice(balder.Device):
        pipe_manager = ManagePipeFeature()
        pipe_sender = PipeSenderFeature()

    @balder.connect(PipeDevice, over_connection=balder.Connection)
    class MirrorDevice(balder.Device):
        mirror = PipeMirrorAutonomousFeature()  # autonomous-device

Bind features

One of the major advantages of Balder is its ability to reuse components, and this applies to features as well. However, you often won’t use them under exactly the same conditions.

That’s where binding features comes in!

Features can be bound to peer devices, that needs to provide a specific feature-set. This can be done with VDevice classes, that are used within features to specify that you need another device with certain defined features - one that interacts with the current device implementing the feature.

If you want to restrict the connections that the feature can use with this VDevice, you can bind the feature class itself. This approach is called class-based binding.

Additionally, you can bind individual methods of your feature to a subset of the allowed sub-connection tree, or limit them to use with a specific VDevice only. This lets you define the same method multiple times, each with different @for_vdevice bindings. For example, you could implement a send() method that applies when the assigned VDevice is connected via a TcpConnection. You could then add another send() method bound to a UdpConnection. Depending on the current scenario or setup, Balder will automatically select and use the correct method variation of send() when you call it in your test case. This is called method-based binding.

This section provides a general overview of how this mechanism works. For more detailed explanations, refer to the VDevices and method-variations section.

Class-Based-Binding

Class-Based-Binding can be defined with a Feature class decorator @for_vdevice().

# file `lib/scenario_features/pipe_send_receive_feature.py`
import balder
from lib.connections import PipeConnection

@balder.for_vdevice("OtherPipeVDevice", with_connections=[PipeConnection])
class PipeSendReceiveFeature(balder.Feature):

    class OtherPipeVDevice(balder.VDevice):
        ...

The example illustrates that this feature requires a PipeConnection for every connection with the mapped device of the inner VDevice OtherPipeVDevice.

But how do you assign which device should be mapped to the VDevice?

You must provide this mapping in the constructor of the feature as a key-value pair. For our example, you add the attribute OtherPipeVDevice="PipeDevice2" to the feature constructor to specify that the scenario device PipeDevice2 should be mapped to the OtherPipeVDevice VDevice.

# file `scenarios/my_scenario.py`
import balder
from lib.connections import PipeConnection
from lib.scenario_features.pipe_send_receive_feature import PipeSendReceiveFeature

class ScenarioMy(balder.Scenario):

    @balder.connect("PipeDevice2", over_connection=PipeConnection)
    class PipeDevice1(balder.Device):
        # Scenario-Device `PipeDevice2` will be mapped to VDevice `OtherPipeVDevice`
        pipe = PipeSendReceiveFeature(OtherPipeVDevice="PipeDevice2")

    class PipeDevice2(balder.Device):
        ...

    ...

Note

In Python, whether you can directly reference a device class inside the @balder.connect(..) decorator or in the VDevice mapping in constructor depends on the order of definitions in your code. If the device class is defined before the decorator, you can reference it directly; otherwise, you cannot. To handle this, Balder also allows you to provide the device reference as a string instead.

You can apply this mapping to different devices, which might represent various usages of the same feature. For instance, you could also specify that the PipeSendReceiveFeature feature of PipeDevice2 should use a VDevice-device mapping with PipeDevice1 as well:

# file `scenarios/my_scenario.py`
import balder
from lib.connections import PipeConnection
from lib.scenario_features.pipe_send_receive_feature import PipeSendReceiveFeature

class ScenarioMy(balder.Scenario):

    @balder.connect("PipeDevice2", over_connection=PipeConnection)
    class PipeDevice1(balder.Device):
        pipe = PipeSendReceiveFeature(OtherPipeVDevice="PipeDevice2")

    class PipeDevice2(balder.Device):
        pipe = PipeSendReceiveFeature(OtherPipeVDevice="PipeDevice1")

    ...

The @balder.connect(PipeDevice2, over_connection=PipeConnection) is necessary here because, when using the PipeSendReceiveFeature, we must fulfill the requirement defined by @balder.for_vdevice("OtherPipeVDevice", with_connections=[PipeConnection]). Since we’ve specified that we want to use the other scenario device as our VDevice, we also need to ensure the appropriate PipeConnection exists between them.

As you can see, this scenario defines that both devices - PipeDevice1 and PipeDevice2 - are connected via a PipeConnection. If we tried to use our feature without this connection between the devices, it would lead to an error, since the PipeSendReceiveFeature simply wouldn’t apply in that case.

Note

Balder verifies whether the requirement specified through the Class-Based-Binding is met. If the requirement does not match the class-based declaration, it raises an error!

You can use this mechanism at the setup level too. If you’d like to learn more about it, check out the VDevices and method-variations section.

Method-Based-Binding

Often, it is necessary to provide different implementations for different VDevices or different sub-connection trees within a single feature. To achieve this, you can use Method-Based-Binding.

Let’s assume we have a feature that can send a message to another device. In this case, the connection type doesn’t really matter, because the feature should generally support this requirement over any possible connection. The key point is simply to test that the device can send a message to another device - it doesn’t matter how the feature sends this message (at least at the scenario level).

This is different, when we want to build universal (setup-level) features. Of course, we could build multiple features, one for TCP and another for serial, but sometimes it makes sense to summarize the logic within one features. With Balder, you can implement the same method multiple times and decorate it with a specific requirement, that needs to be fulfilled so that this method is executed.

This is different when we want to create universal features at the setup level. Of course, we could create multiple separate features, but sometimes it makes sense to consolidate the logic within a single feature. With Balder, you can implement the same method multiple times and decorate each variation with a specific requirement that must be met for that particular implementation to be executed.

In this example, we want to implement the messaging feature for one or more devices that can send messages over TCP or over a serial connection. To do this, we’ll add one VDevice that represents the receiving side by including the feature RecvMessengerFeature in it. Our sending feature itself will have two possible methods for sending the message: one for sending over TCP and another for sending over serial.

Basically, our scenario-level implementation looks like:

# file `lib/scenario_features.py`
import balder
from balder.connections import TcpConnection
from lib.connections import SerialConnection

...

@balder.for_vdevice('OtherVDevice', with_connections=TcpConnection | SerialConnection)
class SendMessengerFeature(balder.Feature):

    class OtherVDevice(balder.VDevice):
        msg = RecvMessengerFeature()

    def send(msg) -> None:
        raise NotImplementedError("this method has to be implemented in setup")

As you can see, we’ve defined the inner VDevice OtherVDevice here. We want to associate the feature RecvMessengerFeature with this VDevice. To do this, we instantiate it as a class property of the OtherVDevice. This enables us to specify the requirements that the mapped device must implement directly within this feature.

Note

The elements specified in the inner VDevice class definition are MUST HAVE. This means that they must be available in the mapped device later on; otherwise, Balder will raise an error.

Up to now, the scenario feature doesn’t use any Method-Based-Bindings. This will change shortly when we implement the setup-level representation of this feature.

Before we proceed with the setup implementation, let’s create a Scenario that uses this newly created feature. To do this, we’ll implement an example scenario with two devices that communicate with each other.

# file `scenarios/scenario_my.py`
import balder
import balder.connections as cnn
from lib import scenario_features
from lib.connections import SerialConnection

class ScenarioMy(balder.Scenario):

    class SendDevice(balder.Device):
        send = scenario_features.SendMessengerFeature(RecvVDevice="RecvDevice")

    # on scenario level, we are using a serial OR a TCP connection
    @balder.connect(SendDevice, over_connection=SerialConnection | cnn.TcpConnection)
    class RecvDevice(balder.VDevice):
        recv = scenario_features.RecvMessengerFeature(SendVDevice="SendDevice")

    def test_check_communication(self):
        SEND_DATA = "Hello World"
        self.RecvDevice.recv.start_async_receiving()
        self.SendDevice.send.send(SEND_DATA)
        all_messages = self.RecvDevice.recv.get_msgs()
        assert len(all_messages) == 1, "have not received anything"
        assert all_messages[0] == SEND_DATA, "have not received the sent data

As you can see, we’ve created a mapping for the inner VDevice to a real, defined scenario Device by using the name of the inner VDevice as the key and the name of the real device as the value in the constructor. We’ve also implemented a basic test to check the communication.

Now, let’s move on to implementing the setup level. We can create our earlier defined feature by simply inheriting from it. In this child class, we want to provide two different implementations for our abstract method send - one for a serial connection and another for a TCP connection. To achieve this, we can use the Method-Based-Binding decorator:

# file `lib/setup_features.py`
import balder
from balder.connections import TcpConnection
from lib import scenario_features
from .connections import SerialConnection

class SetupSendMessengerFeature(scenario_features.SendMessengerFeature):

    @balder.for_vdevice(scenario_features.SendMessengerFeature.OtherVDevice, with_connections=SerialConnection)
    def send(self, msg) -> None:
        serial = MySerial(com=..)
        ...

    @balder.for_vdevice(scenario_features.SendMessengerFeature.OtherVDevice, with_connections=TcpConnection)
    def send(self, msg) -> None:
        sock = socket.socket(...)
        ...

As you can see, you can provide completely different implementations for the various sub-connection types. Depending on the actual connection used (that is, the specific sub-connection over which the setup devices are linked to each other), Balder will automatically call the corresponding method variation.

Now, let’s take a look at the following Setup that matches our Scenario and supports both connections.

# file `setups/setup_my.py`
import balder
import balder.connections as cnn
from lib import setup_features

class MySetup(balder.Setup):

    @balder.connect(SlaveDevice, over_connection=cnn.TcpConnection)
    class MainDevice(balder.Device):
        msg = setup_features.SetupSendMessengerFeature()

    class SlaveDevice(balder.Device):
        recv = setup_features.SetupRecvMessengerFeature()

This example connects the two relevant devices over a TcpConnection with each other, because the scenario defines, that the devices should be connected over an TcpConnection. If the test now uses on of our methods SendMessengerFeature.send(..), the variation with the decorator @balder.for_vdevice(..., over_connection=TcpConnection) will be used.

If one would exchange the connection with the SerialConnection, Balder would select the method variation with the decorator @balder.for_vdevice(..., with_connection=SerialConnection).

This example connects the two relevant devices to each other via a TcpConnection. When the test calls one of our methods, such as MyMessengerFeature.send(..), Balder will use the method variation decorated with @balder.for_vdevice(..., over_connection=TcpConnection).

If we were to replace that connection with a SerialConnection, Balder would instead select the method variation decorated with @balder.for_vdevice(..., with_connection=SerialConnection).

Note

In the setup example, there is no VDevice-device mapping. This isn’t necessary, as we’ve already specified it at the scenario level.

Feature inheritance

Warning

This section is still under development.