Repository: cocotb/cocotb

Issue:
Support `Coroutine` classes and objects wherever coroutines are
Currently we do runtime type checks in many places where coroutine functions and objects are accepted. These type checks prevent `Coroutine` classes and objects from being used.


Discussion:

Resolving patch:
diff --git a/cocotb/decorators.py b/cocotb/decorators.py
--- a/cocotb/decorators.py
+++ b/cocotb/decorators.py
@@ -30,6 +30,7 @@
 import inspect
 import os
 import warnings
+import collections.abc
 
 import cocotb
 from cocotb.log import SimLog
@@ -86,11 +87,12 @@ class RunningTask:
         triggers to fire.
     """
 
+    _name: str = "Task"  # class name of schedulable task
     _id_count = 0  # used by the scheduler for debug
 
     def __init__(self, inst):
 
-        if inspect.iscoroutine(inst):
+        if isinstance(inst, collections.abc.Coroutine):
             self._natively_awaitable = True
         elif inspect.isgenerator(inst):
             self._natively_awaitable = False
@@ -115,7 +117,7 @@ def __init__(self, inst):
 
         self._task_id = self._id_count
         RunningTask._id_count += 1
-        self.__name__ = "Task %d" % self._task_id
+        self.__name__ = f"{type(self)._name} {self._task_id}"
         self.__qualname__ = self.__name__
 
     @lazy_property
@@ -268,6 +270,16 @@ def __init__(self, inst, parent):
         self.funcname = parent._func.__name__
 
 
+class RunningTest(RunningCoroutine):
+    """
+    The result of calling a :class:`cocotb.test` decorated object.
+
+    All this class does is change ``__name__`` to show "Test" instead of "Task".
+    """
+
+    _name: str = "Test"
+
+
 class coroutine:
     """Decorator class that allows us to provide common coroutine mechanisms:
 
@@ -375,12 +387,11 @@ def decorator(f):
 
 @public
 class test(coroutine, metaclass=_decorator_helper):
-    """Decorator to mark a function as a test.
-
-    All tests are coroutines.  The test decorator provides
-    some common reporting etc., a test timeout and allows
-    us to mark tests as expected failures.
+    """
+    Decorator to mark a Callable which returns a Coroutine as a test.
 
+    The test decorator provides a test timeout, and allows us to mark tests as skipped
+    or expecting errors or failures.
     Tests are evaluated in the order they are defined in a test module.
 
     Used as ``@cocotb.test(...)``.
@@ -488,7 +499,5 @@ async def f(*args, **kwargs):
 
     def __call__(self, *args, **kwargs):
         inst = self._func(*args, **kwargs)
-        coro = RunningCoroutine(inst, self)
-        coro.__name__ = "Test {}".format(inst.__name__)
-        coro.__qualname__ = "Test {}".format(inst.__qualname__)
+        coro = RunningTest(inst, self)
         return coro
diff --git a/cocotb/regression.py b/cocotb/regression.py
--- a/cocotb/regression.py
+++ b/cocotb/regression.py
@@ -35,6 +35,7 @@
 import traceback
 import pdb
 from typing import Any, Optional, Tuple, Iterable
+from functools import wraps
 
 import cocotb
 import cocotb.ANSI as ANSI
@@ -617,13 +618,11 @@ def _create_test(function, name, documentation, mod, *args, **kwargs):
     Returns:
         Decorated test function
     """
+
+    @wraps(function)
     async def _my_test(dut):
         await function(dut, *args, **kwargs)
 
-    _my_test.__name__ = name
-    _my_test.__qualname__ = name
-    _my_test.__doc__ = documentation
-    _my_test.__module__ = mod.__name__
     return cocotb.test()(_my_test)
 
 
@@ -631,7 +630,7 @@ class TestFactory:
     """Factory to automatically generate tests.
 
     Args:
-        test_function: The function that executes a test.
+        test_function: A Callable that returns the test Coroutine.
             Must take *dut* as the first argument.
         *args: Remaining arguments are passed directly to the test function.
             Note that these arguments are not varied. An argument that
@@ -687,12 +686,6 @@ class TestFactory:
     __test__ = False
 
     def __init__(self, test_function, *args, **kwargs):
-        if sys.version_info > (3, 6) and inspect.isasyncgenfunction(test_function):
-            raise TypeError("Expected a coroutine function, but got the async generator '{}'. "
-                            "Did you forget to convert a `yield` to an `await`?"
-                            .format(test_function.__qualname__))
-        if not (isinstance(test_function, cocotb.coroutine) or inspect.iscoroutinefunction(test_function)):
-            raise TypeError("TestFactory requires a cocotb coroutine")
         self.test_function = test_function
         self.name = self.test_function.__qualname__
 
diff --git a/cocotb/scheduler.py b/cocotb/scheduler.py
--- a/cocotb/scheduler.py
+++ b/cocotb/scheduler.py
@@ -696,7 +696,7 @@ def create_task(coroutine: Any) -> RunningTask:
 
         if isinstance(coroutine, RunningTask):
             return coroutine
-        if inspect.iscoroutine(coroutine):
+        if isinstance(coroutine, Coroutine):
             return RunningTask(coroutine)
         if inspect.iscoroutinefunction(coroutine):
             raise TypeError(


Test patch:
diff --git a/tests/test_cases/test_cocotb/test_testfactory.py b/tests/test_cases/test_cocotb/test_testfactory.py
--- a/tests/test_cases/test_cocotb/test_testfactory.py
+++ b/tests/test_cases/test_cocotb/test_testfactory.py
@@ -4,6 +4,8 @@
 """
 Tests of cocotb.regression.TestFactory functionality
 """
+from collections.abc import Coroutine
+
 import cocotb
 from cocotb.regression import TestFactory
 
@@ -28,3 +30,26 @@ async def test_testfactory_verify_args(dut):
         ("a1v1", "a2v2", "a3v2"),
         ("a1v2", "a2v2", "a3v2"),
     }
+
+
+class TestClass(Coroutine):
+
+    def __init__(self, dut, myarg):
+        self._coro = self.run(dut, myarg)
+
+    async def run(self, dut, myarg):
+        assert myarg == 1
+
+    def send(self, value):
+        self._coro.send(value)
+
+    def throw(self, exception):
+        self._coro.throw(exception)
+
+    def __await__(self):
+        yield from self._coro.__await__()
+
+
+tf = TestFactory(TestClass)
+tf.add_option("myarg", [1])
+tf.generate_tests()
diff --git a/tests/test_cases/test_cocotb/test_tests.py b/tests/test_cases/test_cocotb/test_tests.py
--- a/tests/test_cases/test_cocotb/test_tests.py
+++ b/tests/test_cases/test_cocotb/test_tests.py
@@ -8,6 +8,8 @@
 * expect_fail
 * timeout
 """
+from collections.abc import Coroutine
+
 import cocotb
 from cocotb.triggers import Timer
 from cocotb.result import TestFailure
@@ -106,3 +108,22 @@ async def test_ordering_1(dut):
     global last_ordered_test
     val, last_ordered_test = last_ordered_test, 1
     assert val == 2
+
+
+@cocotb.test()
+class TestClass(Coroutine):
+
+    def __init__(self, dut):
+        self._coro = self.run(dut)
+
+    async def run(self, dut):
+        pass
+
+    def send(self, value):
+        self._coro.send(value)
+
+    def throw(self, exception):
+        self._coro.throw(exception)
+
+    def __await__(self):
+        yield from self._coro.__await__()