One of the patterns I like is using a context manager to make sure bringup and cleanup are called for each test. Let’s have a look at context managers.

Context Manager and Async Context Manager Link to heading

I will just reference the official PEP 343

This PEP adds a new statement “with” to the Python language to make it possible to factor out standard uses of try/finally statements.

In this PEP, context managers provide enter() and exit() methods that are invoked on entry to and exit from the body of the with statement.

Essentially, using with we can ensure __enter__ and __exit__ are called to do the initialization and cleanup code.

with open(filename) as f:
    ...read data from f...

Then in PEP 492, they introduced the awaitable version of a context manager:

An asynchronous context manager is a context manager that is able to suspend execution in its enter and exit methods.

To make this possible, a new protocol for asynchronous context managers is proposed. Two new magic methods are added: aenter and aexit. Both must return an awaitable.

class AsyncContextManager:
    async def __aenter__(self):
        await log('entering context')

    async def __aexit__(self, exc_type, exc, tb):
        await log('exiting context')

cocotb Context Manager Link to heading

It’s always good to have self-contained reset, bringup, and teardown methods so we can just call them in __aenter__ and __aexit__.

That said, there is a simpler way of doing this (using __init__ and finally), but the context manager is too elegant to give up.

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import ClockCycles

class TestBench:
    def __init__(self, dut):
        self.dut = dut
        self.clock_task = None

    async def reset(self):
        cocotb.log.info("Applying reset")
        self.dut.rst_n.value = 0
        await ClockCycles(self.dut.clk, 5)
        
        cocotb.log.info("Releasing reset")
        self.dut.rst_n.value = 1
        await ClockCycles(self.dut.clk, 2)

    async def bringup(self):
        cocotb.log.info("Bringup starting")
        
        # Start the clock
        self.clock_task = cocotb.start_soon(
            Clock(self.dut.clk, 10, unit='ns').start()
        )

        # Reset the DUT
        await self.reset()
        cocotb.log.info("Bringup completed")

    async def teardown(self):
        cocotb.log.info("Teardown starting")

        # Do stuff to leave the DUT in a known state
        self.dut.data_in.value = 0
        await self.reset()

        # Stop the clock
        self.clock_task.cancel()

        cocotb.log.info("Teardown completed")

    async def __aenter__(self):
        await self.bringup()
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await self.teardown()
        return False
     
@cocotb.test()
async def test_async_context_manager(dut):
    cocotb.log.info("Test started")

    async with TestBench(dut) as tb:
        await ClockCycles(dut.clk, 10)
        dut.data_in.value = 0xDEADBEEF
        cocotb.log.info(f"Set data_in = 0x{int(dut.data_in.value):X}")
        await ClockCycles(dut.clk, 5)
        
    cocotb.log.info("Test Completed")
     0.00ns INFO     test                               Test started
     0.00ns INFO     test                               Bringup starting
     0.00ns INFO     test                               Applying reset
    40.00ns INFO     test                               Releasing reset
    60.00ns INFO     test                               Bringup completed
   160.00ns INFO     test                               Set data_in = 0xDEADBEEF
   210.00ns INFO     test                               Teardown starting
   210.00ns INFO     test                               Applying reset
   260.00ns INFO     test                               Releasing reset
   280.00ns INFO     test                               Teardown completed
   280.00ns INFO     test                               Test Completed