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)