Context

The context is the new mechanism in tbot for managing machine instances. Under the hood, the old configuration mechanism and the tbot.selectable module are already using the context but using it directly gives you even more power!

Note

If you want to migrate from the “old” way of accessing the configured machines, please read the Migrating to Context guide at the end of this page. Though it is a good idea to first familiarize yourself with the new concepts.

The idea

At the core, everything revolves around a context object:

  • Configuration registers machines with the context object (See Configuration).

  • Testcases can then request instances of registered machines (See The tbot.ctx object).

Machines are registered for fulfilling certain roles. For example the lab-config should register a machine for the tbot.role.LabHost role while the board-config registers machines for e.g. tbot.role.Board and tbot.role.BoardLinux.

When a testcase requests a machine, the instance that is created is cached. That means when a later testcase requests the same machine (before the first one released it again!), it will get access to the same instance.

The tbot.ctx object

tbot defines a global context as tbot.ctx. Testcases can directly interact with this context to access machines. See the tbot.Context class for the full API. Essentially, there are two design patterns for testcases:

Single Machine

@tbot.testcase
def test_with_labhost():
    with tbot.ctx.request(tbot.role.LabHost) as lh:
        lh.exec0("uname", "-a")

Multiple Machines

This is analogous to pythons own contextlib.ExitStack and useful when you need multiple machines (= multiple context-managers) in one testcase:

@tbot.testcase
def test_with_board_and_lab():
   with tbot.ctx() as cx:
      lh = cx.request(tbot.role.LabHost)
      lnx = cx.request(tbot.role.BoardLinux)

      lh.exec0("hostname")
      lnx.exec0("hostname")

Keeping tests flexible

When writing reusable testcases, you should always prepare them for situations where a caller would want to pass in custom machines instead of the ones registered in the context. The best way to do this is this:

@tbot.testcase
def reusable_test_one_machine(m: Optional[LinuxShell] = None):
    with tbot.ctx() as cx:
        if m is None:
            m = cx.request(tbot.role.LabHost)

        ...

@tbot.testcase
def reusable_test_multiple(
    lab: Optional[LinuxShell] = None,
    ub: Optional[UBootShell] = None,
):
    with tbot.ctx() as cx:
        if lab is None:
            lab = cx.request(tbot.role.LabHost)
        if ub is None:
            ub = cx.request(tbot.role.BoardUBoot)

        ...

Todo

Eventually, tbot might grow a new decorator for making contex usage even easier. For now the above patterns are what should be used.

Complex Testcases (e.g. Powercycle)

Sometimes, testcases need to do more complex things with instances than to simply interact with them. A common example would be a powercycle of a DUT. For such cases, the Context.request() method provides some keyword arguments to allow fine-grained control over instance requesting.

For the DUT powercycle example, a testcase might look like this:

@tbot.testcase
def test_with_dut_reboot():
    with tbot.ctx.request(tbot.role.BoardLinux) as lnx:
        # Do some things before powercycle
        lnx.exec0("hwclock", "--systohc")

    with tbot.ctx.request(tbot.role.BoardLinux, reset=True) as lnx:
        # The DUT was powercycled before entering this context
        lnx.exec0("hwclock", "--hctosys")

Roles

The tbot.role module pre-defines a number of roles that are commonly needed in embedded automation and testing. These roles are also what testcases distributed alongside tbot use. As an overview (details are in the tbot.role module documentation):

However, you can also define your own roles for more complex scenarios! The role should inherit tbot.role.Role and any ABCs that a machine-class implementing the role uses. For example, a role for a Linux machine should probably inherit tbot.machine.linux.LinuxShell. Or a role for a U-Boot machine should inherit tbot.machine.board.UBootShell.

Configuration

For tbot.selectable, machines were configured via globals in the lab- and board-config named LAB, BOARD, UBOOT, and LINUX. This still works and will actually register the machines in tbot.ctx under the hood.

The new context-based configuration works slightly different: lab- and board-config scripts should define a global register_machines() function that registers all machines from this config into the supplied context using tbot.Context.register(). The method documentation explains the details of registration but here are two examples:

Example lab-config

class MyLab(...):
    ...

class MyBuildHost(...):
    ...

def register_machines(ctx: tbot.Context) -> None:
    ctx.register(MyLab, tbot.role.LabHost)
    # Optionally register a build-host as well
    ctx.register(MyBuildHost, tbot.role.BuildHost)

    # You could also register MyLab for both LabHost and BuildHost:
    ctx.register(MyLab, [tbot.role.LabHost, tbot.role.BuildHost])

Example board-config

class MyBoard(...):
    ...

class MyBoardLinux(...):
    ...

def register_machines(ctx: tbot.Context) -> None:
    ctx.register(MyBoard, tbot.role.Board)
    ctx.register(MyBoardLinux, tbot.role.BoardLinux)

Controlling machine instantiation

When a testcase calls tbot.Context.request() to request a machine instance, this instance needs to be created which is not trivial in all cases. The context relies on the Connector.from_context() classmethod of the registered machine-class for this.

Most connectors come with a reasonable default implementation of this method which often just requests the prerequisite machines from the context and then constructs the machine-class using them. As an example, here is the implementation of from_context() for ConsoleConnector:

@classmethod
@contextlib.contextmanager
def from_context(cls, ctx: "tbot.Context"):
    with contextlib.ExitStack() as cx:
        # Will try to connect to console from lab-host, thus request
        # lab-host here:
        lh = cx.enter_context(ctx.request(tbot.role.LabHost))

        # Then instantiate the machine-class using `lh`:
        m = cx.enter_context(cls(lh))
        yield m

For more complex scenarios, lab- or board-config can of course overwrite this method with custom behavior. Please keep in mind the special semantics of Connector.from_context() which are detailed in the method documentation.

Migrating to Context

If you are interested in converting existing testcases and configuration to the new “Context” API, this guide is for you. For the most part the changes are small and can be done incrementally as the new API is compatible with old code & the other way around.

Migrating Testcases

The following functions/context-managers can be replaced by equivalent calls to the context:

-with tbot.acquire_lab() as lh:
+with tbot.ctx.request(tbot.role.LabHost) as lh:
     lh.exec0("uname", "-a")

-with tbot.acquire_local() as lo:
+with tbot.ctx.request(tbot.role.LocalHost) as lo:
     lh.exec0("uname", "-a")

For some, you can simplify the code because prerequisites are acquired automatically:

-with tbot.acquire_lab() as lh:
-    with tbot.acquire_board(lh) as b:
+with tbot.ctx.request(tbot.role.Board) as b:
         ...

-with tbot.acquire_lab() as lh:
-    with tbot.acquire_board(lh) as b:
-        with tbot.acquire_uboot(b) as ub:
+with tbot.ctx.request(tbot.role.BoardUBoot) as ub:
             ub.exec0("version")

 # The above was also often done like this:
-with contextlib.ExitStack() as cx:
-    lh = cx.enter_context(tbot.acquire_lab())
-    b = cx.enter_context(tbot.acquire_board(lh))
-    ub = cx.enter_context(tbot.acquire_uboot(b))
+with tbot.ctx.request(tbot.role.BoardUBoot) as ub:
     ub.exec0("version")

 # The same is true for board linux:
-with contextlib.ExitStack() as cx:
-    lh = cx.enter_context(tbot.acquire_lab())
-    b = cx.enter_context(tbot.acquire_board(lh))
-    lnx = cx.enter_context(tbot.acquire_linux(b))
+with tbot.ctx.request(tbot.role.BoardLinux) as lnx:
     lnx.exec0("cat", "/etc/os-release")

The tbot.with_lab(), tbot.with_uboot(), and tbot.with_linux() decorators do not have a direct replacement but because acquring a machine from the context is a single line, the change is not too big either:

 @tbot.testcase
-@tbot.with_lab
-def lab_name(lh):
+def lab_name():
+    with tbot.ctx.request(tbot.role.LabHost) as lh:
         lh.exec0("hostname")

 @tbot.testcase
-@tbot.with_linux
-def some_test_with_linux(lnx):
+def some_test_with_linux():
+    with tbot.ctx.request(tbot.role.BoardLinux) as lnx:
         lnx.exec0("cat", "/etc/os-release")

Note

At some point, a new decorator to replace the existing ones might be introduced. For the time being, the example above shows what needs to be done.

Migrating Configuration

While the old way of configuring tbot still works and is fully compatible with the context API, it only provides limited possibilities. For more complex scenarios, the new Configuration mechanism is much more flexible.

Switching over is not hard: You need to define a register_machines() function in the lab- and/or board-config which replaces the existing LAB =, BOARD =, UBOOT =, and LINUX = statements. For the lab-config, additionally, there is now a cleaner way to define the build-host:

 class MyBuildHost(...):
     ...

 class MyPersonalLab(...):
     ...

-    def build(self):
-        return MyBuildHost(self)

-LAB = MyPersonalLab
+def register_machines(ctx):
+    ctx.register(MyPersonalLab, tbot.role.LabHost)
+    ctx.register(MyBuildHost, tbot.role.BuildHost)

For the board-config it is even more straight-forward:

 class MyBoard(...):
     ...

 class MyUBoot(...):
     ...

 class MyBoardLinux(...):
     ...

+def register_machines(ctx):
-BOARD = MyBoard
+    ctx.register(MyBoard, tbot.role.Board)
-UBOOT = MyUBoot
+    ctx.register(MyUBoot, tbot.role.BoardUBoot)
-LINUX = MyBoardLinux
+    ctx.register(MyBoardLinux, tbot.role.BoardLinux)