Repository: cocotb/cocotb

Issue:
Support forking non-decorated async coroutines
Support the following example of forking an non-decorated async coroutine.

```python
async def example():
    for i in range(10):
        await cocotb.triggers.Timer(10, 'ns')

cocotb.fork(example())
```


Discussion:
To get hold of generator objects:
```
e = example()
gen = e.__await__()  # generator-like thing
name = e.__name__
```
As mentioned in a now-deleted comment, it's not clear how to get the module name - but I think that's ok.
@eric-wieser My solution is just to get rid of the module name, etc. code from `RunningCoroutine`, it's only used by `RunningTest` anyways, which will always be decorated.

Resolving patch:
diff --git a/cocotb/decorators.py b/cocotb/decorators.py
--- a/cocotb/decorators.py
+++ b/cocotb/decorators.py
@@ -76,41 +76,33 @@ def __init__(self, text=""):
         Exception.__init__(self, text)
 
 
-class RunningCoroutine(object):
-    """Per instance wrapper around an function to turn it into a coroutine.
+class RunningTask(object):
+    """Per instance wrapper around a running generator.
 
     Provides the following:
 
-        coro.join() creates a Trigger that will fire when this coroutine
+        task.join() creates a Trigger that will fire when this coroutine
         completes.
 
-        coro.kill() will destroy a coroutine instance (and cause any Join
+        task.kill() will destroy a coroutine instance (and cause any Join
         triggers to fire.
     """
-    def __init__(self, inst, parent):
-        if hasattr(inst, "__name__"):
-            self.__name__ = "%s" % inst.__name__
+    def __init__(self, inst):
 
-        if sys.version_info[:2] >= (3, 5) and inspect.iscoroutine(inst):
+        if inspect.iscoroutine(inst):
             self._natively_awaitable = True
             self._coro = inst.__await__()
-        else:
+        elif inspect.isgenerator(inst):
             self._natively_awaitable = False
             self._coro = inst
+        else:
+            raise TypeError(
+                "%s isn't a valid coroutine! Did you forget to use the yield keyword?" % inst)
+        self.__name__ = "%s" % inst.__name__
         self._started = False
         self._callbacks = []
-        self._parent = parent
-        self.__doc__ = parent._func.__doc__
-        self.module = parent._func.__module__
-        self.funcname = parent._func.__name__
         self._outcome = None
 
-        if not hasattr(self._coro, "send"):
-            raise TypeError(
-                "%s isn't a valid coroutine! Did you use the yield "
-                "keyword?" % self.funcname
-            )
-
     @lazy_property
     def log(self):
         # Creating a logger is expensive, only do it if we actually plan to
@@ -214,11 +206,20 @@ def __await__(self):
 
     __bool__ = __nonzero__
 
-    def sort_name(self):
-        if self.stage is None:
-            return "%s.%s" % (self.module, self.funcname)
-        else:
-            return "%s.%d.%s" % (self.module, self.stage, self.funcname)
+
+class RunningCoroutine(RunningTask):
+    """
+    The result of calling a :any:`cocotb.coroutine` decorated coroutine.
+
+    All this class does is provide some extra attributes.
+    """
+    def __init__(self, inst, parent):
+        RunningTask.__init__(self, inst)
+        self._parent = parent
+        self.__doc__ = parent._func.__doc__
+        self.module = parent._func.__module__
+        self.funcname = parent._func.__name__
+
 
 class RunningTest(RunningCoroutine):
     """Add some useful Test functionality to a RunningCoroutine."""
@@ -268,7 +269,7 @@ def _force_outcome(self, outcome):
         self._outcome = outcome
         cocotb.scheduler.unschedule(self)
 
-    # like RunningCoroutine.kill(), but with a way to inject a failure
+    # like RunningTask.kill(), but with a way to inject a failure
     def abort(self, exc):
         """Force this test to end early, without executing any cleanup.
 
@@ -281,13 +282,19 @@ def abort(self, exc):
         """
         return self._force_outcome(outcomes.Error(exc))
 
+    def sort_name(self):
+        if self.stage is None:
+            return "%s.%s" % (self.module, self.funcname)
+        else:
+            return "%s.%d.%s" % (self.module, self.stage, self.funcname)
+
 
 class coroutine(object):
     """Decorator class that allows us to provide common coroutine mechanisms:
 
     ``log`` methods will log to ``cocotb.coroutine.name``.
 
-    :meth:`~cocotb.decorators.RunningCoroutine.join` method returns an event which will fire when the coroutine exits.
+    :meth:`~cocotb.decorators.RunningTask.join` method returns an event which will fire when the coroutine exits.
 
     Used as ``@cocotb.coroutine``.
     """
diff --git a/cocotb/scheduler.py b/cocotb/scheduler.py
--- a/cocotb/scheduler.py
+++ b/cocotb/scheduler.py
@@ -38,6 +38,7 @@
 import sys
 import logging
 import threading
+import inspect
 
 # Debug mode controlled by environment variables
 if "COCOTB_ENABLE_PROFILING" in os.environ:
@@ -654,7 +655,10 @@ def add(self, coroutine):
                 .format(coroutine)
             )
 
-        elif not isinstance(coroutine, cocotb.decorators.RunningCoroutine):
+        if inspect.iscoroutine(coroutine):
+            return self.add(cocotb.decorators.RunningTask(coroutine))
+
+        elif not isinstance(coroutine, cocotb.decorators.RunningTask):
             raise TypeError(
                 "Attempt to add a object of type {} to the scheduler, which "
                 "isn't a coroutine: {!r}\n"
@@ -678,19 +682,19 @@ def add_test(self, test_coro):
 
     # This collection of functions parses a trigger out of the object
     # that was yielded by a coroutine, converting `list` -> `Waitable`,
-    # `Waitable` -> `RunningCoroutine`, `RunningCoroutine` -> `Trigger`.
-    # Doing them as separate functions allows us to avoid repeating unnecessary
+    # `Waitable` -> `RunningTask`, `RunningTask` -> `Trigger`.
+    # Doing them as separate functions allows us to avoid repeating unencessary
     # `isinstance` checks.
 
     def _trigger_from_started_coro(self, result):
-        # type: (cocotb.decorators.RunningCoroutine) -> Trigger
+        # type: (cocotb.decorators.RunningTask) -> Trigger
         if _debug:
             self.log.debug("Joining to already running coroutine: %s" %
                            result.__name__)
         return result.join()
 
     def _trigger_from_unstarted_coro(self, result):
-        # type: (cocotb.decorators.RunningCoroutine) -> Trigger
+        # type: (cocotb.decorators.RunningTask) -> Trigger
         self.queue(result)
         if _debug:
             self.log.debug("Scheduling nested coroutine: %s" %
@@ -712,12 +716,15 @@ def _trigger_from_any(self, result):
         if isinstance(result, Trigger):
             return result
 
-        if isinstance(result, cocotb.decorators.RunningCoroutine):
+        if isinstance(result, cocotb.decorators.RunningTask):
             if not result.has_started():
                 return self._trigger_from_unstarted_coro(result)
             else:
                 return self._trigger_from_started_coro(result)
 
+        if inspect.iscoroutine(result):
+            return self._trigger_from_unstarted_coro(cocotb.decorators.RunningTask(result))
+
         if isinstance(result, list):
             return self._trigger_from_list(result)
 
diff --git a/cocotb/triggers.py b/cocotb/triggers.py
--- a/cocotb/triggers.py
+++ b/cocotb/triggers.py
@@ -635,7 +635,7 @@ def __init__(self, *triggers):
 
         # Do some basic type-checking up front, rather than waiting until we
         # yield them.
-        allowed_types = (Trigger, Waitable, decorators.RunningCoroutine)
+        allowed_types = (Trigger, Waitable, decorators.RunningTask)
         for trigger in self.triggers:
             if not isinstance(trigger, allowed_types):
                 raise TypeError(


Test patch:
diff --git a/tests/test_cases/test_cocotb/test_cocotb_35.py b/tests/test_cases/test_cocotb/test_cocotb_35.py
--- a/tests/test_cases/test_cocotb/test_cocotb_35.py
+++ b/tests/test_cases/test_cocotb/test_cocotb_35.py
@@ -116,3 +116,28 @@ async def test_await_causes_start(dut):
     assert not coro.has_started()
     await coro
     assert coro.has_started()
+
+
+@cocotb.test()
+def test_undecorated_coroutine_fork(dut):
+    ran = False
+
+    async def example():
+        nonlocal ran
+        await cocotb.triggers.Timer(1, 'ns')
+        ran = True
+
+    yield cocotb.fork(example()).join()
+    assert ran
+
+@cocotb.test()
+def test_undecorated_coroutine_yield(dut):
+    ran = False
+
+    async def example():
+        nonlocal ran
+        await cocotb.triggers.Timer(1, 'ns')
+        ran = True
+
+    yield example()
+    assert ran