Repository: cocotb/cocotb

Issue:
Allow a custom entry point
Currently, the entry point from C to python is hard-coded here:

https://github.com/cocotb/cocotb/blob/17ce4e51da43b6d2d1b5834d629374ed5535a8d8/cocotb/share/lib/embed/gpi_embed.c#L398

Which is

https://github.com/cocotb/cocotb/blob/ba0fb097efbd2939ad6a961ee8d02793104ef8f7/cocotb/__init__.py#L108

From about here, the things it does become opinionated:

https://github.com/cocotb/cocotb/blob/ba0fb097efbd2939ad6a961ee8d02793104ef8f7/cocotb/__init__.py#L154-L172

It would be great if a user could set an env var or plusarg to launch into a different entry point


Discussion:
Plusargs don't work with Questa. Don't know why anyone didn't think of that when they added the feature. I'm not a fan of environment variables when we could use a Python module (or modules).

This would require some modification of the scheduler and regression manager. They both reach into eachother by way of global scheduler and regression manager variables. Custom code might not use either.
https://github.com/cocotb/cocotb/blob/17ce4e51da43b6d2d1b5834d629374ed5535a8d8/cocotb/__init__.py#L89-L92
>  I'm not a fan of environment variables when we could use a Python module (or modules).

There are two approaches to this problem I've seen in the wild:

* `conftest.py` (pytest), `conf.py` (Sphinx) - this isn't really an entry point, but is an example of settings being loaded from a python file. Note that I'm pretty sure this isn't imported the usual way, but is read from the file system and then exec'd, which gives more flexible rules about where it can live.
* a `--entry-point=my.python_module:my_func` syntax, which is how other runtime environments (eg the python WSGI web server hosts, `cherrypy`, `gunicorn`, etc) let you choose an entry point. The dot and colon syntax is a `setuptools` protocol, so seems sensible to reuse.

My preference is towards the latter, because a the world with arbitrary entry points (eg IPython), cocotb feels more like a runtime environment than a configurable test suite.

I think @ktbarrett's comments above are in favor of something like the former.
>I think @ktbarrett's comments above are in favor of something like the former.

Correct. I don't see a reason that normal imports can't be used. After looking at your examples, I like pytest's approach of calling well defined hooks, but instead of hooks overrides. The overrides can call the default implementation if they just want to (pre/ap)pend behavior.

>because a the world with arbitrary entry points (eg IPython), cocotb feels more like a runtime environment than a configurable test suite

And why is that a bad thing? There is a lot of useful technology in cocotb that is difficult to reuse because of how coupled everything is. GPI could be a separate repo entirely and could load into any arbitrary python module. Cocotb could exist as a configurable test suite that uses GPI. At that point an interactive testing framework using IPython would simply be another consumer of the GPI. I think the problem is that people are thinking about cocotb too much as "just another Python package".
> And why is that a bad thing?

I don't think it is at all! My point was that if we're a runtime environment, the wsgi model is a closer fit than the pytest one.
Oh sorry, I usually get a lot of resistance at work. I'm projecting. So you're saying push a module and entry function via an environment variable for C to load as an application entry inplace of hardcoding `cocotb` and `_initialize_testbench`? If so, I wholeheartedly agree.
Yes, that's pretty much what I'm suggesting
Would you mind outlining an end-user use case for such a functionality? (What problem are we solving?)
@imphil The first thing that comes to mind is entry into IPython or a JupyterNotebook for interactive testing rather than the regression manager. Think PYNQ, but with a simulator instead of hardware. Perhaps even alternatives to the regression manager, like pytest could be easily prototyped. Doing so would allow developers of those alternatives to develop outside of the main cocotb repo without having to maintain a fork of the entire repo, which is good for all parties. There will need to be some refactoring to give cocotb (the Python framework) a more consistent interface for the GPI to effectively black-box it. A custom entry point is just one step of many.
@ktbarrett has nailed the motivation there - ipython and pytest are the two most relevant use-cases in my mind.
While we're discussing the idea of custom entry points to Python, it's also worth considering decoupling loading GPI from embedding Python.
That allows the use of GPI with testing frameworks in other languages, or using some kind of ipc.

Some discussion of this in #1181.
@garmin-mjames Thank you for mentioning that.

Cocotb really should be many libraries IMO. GPI, PyGPI, and any number of Python testing frameworks whether it be the current regression manager, pytest-based, interactive, etc. I don't know if the support for that should be in this repo, or if most of the simulator code should be moved into another repo, or if the whole thing just makes people uncomfortable.

Resolving patch:
diff --git a/cocotb/__init__.py b/cocotb/__init__.py
--- a/cocotb/__init__.py
+++ b/cocotb/__init__.py
@@ -41,13 +41,13 @@
 from collections.abc import Coroutine
 
 import cocotb.handle
-import cocotb.log
 from cocotb.scheduler import Scheduler
 from cocotb.regression import RegressionManager
 from cocotb.decorators import RunningTask
 
 # Things we want in the cocotb namespace
 from cocotb.decorators import test, coroutine, function, external  # noqa: F401
+from cocotb.log import _log_from_c, _filter_from_c  # noqa: F401
 
 from ._version import __version__
 
@@ -73,7 +73,8 @@ def _reopen_stream_with_buffering(stream_name):
 
     # Don't set the logging up until we've attempted to fix the standard IO,
     # otherwise it will end up connected to the unfixed IO.
-    cocotb.log.default_config()
+    from cocotb.log import default_config
+    default_config()
     log = logging.getLogger(__name__)
 
     # we can't log these things until the logging is set up!
@@ -226,7 +227,7 @@ def _initialise_testbench(argv_):  # pragma: no cover
                 include=["{}/*".format(os.path.dirname(__file__))])
             _library_coverage.start()
 
-        return _initialise_testbench_(argv_)
+        _initialise_testbench_(argv_)
 
 
 def _initialise_testbench_(argv_):
@@ -331,8 +332,6 @@ def _initialise_testbench_(argv_):
     regression_manager = RegressionManager.from_discovery(top)
     regression_manager.execute()
 
-    return True
-
 
 def _sim_event(level, message):
     """Function that can be called externally to signal an event."""
@@ -353,8 +352,6 @@ def _sim_event(level, message):
     else:
         scheduler.log.error("Unsupported sim event")
 
-    return True
-
 
 def process_plusargs():
 
diff --git a/pygpi/__init__.py b/pygpi/__init__.py
new file mode 100644
diff --git a/pygpi/entry.py b/pygpi/entry.py
new file mode 100644
--- /dev/null
+++ b/pygpi/entry.py
@@ -0,0 +1,20 @@
+import os
+import importlib
+from functools import reduce
+from types import ModuleType
+from typing import Tuple, Callable
+
+
+def load_entry() -> Tuple[ModuleType, Callable]:
+    """Gather entry point information by parsing :envvar:`PYGPI_ENTRY_POINT`."""
+    entry_point_str = os.environ.get("PYGPI_ENTRY_POINT", "cocotb:_initialise_testbench")
+    try:
+        if ":" not in entry_point_str:
+            raise ValueError("Invalid PYGPI_ENTRY_POINT, missing entry function (no colon).")
+        entry_module_str, entry_func_str = entry_point_str.split(":", 1)
+        entry_module = importlib.import_module(entry_module_str)
+        entry_func = reduce(getattr, entry_func_str.split('.'), entry_module)
+    except Exception as e:
+        raise RuntimeError("Failure to parse PYGPI_ENTRY_POINT ('{}')".format(entry_point_str)) from e
+    else:
+        return entry_module, entry_func


Test patch:
diff --git a/tests/test_cases/test_custom_entry/.gitignore b/tests/test_cases/test_custom_entry/.gitignore
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_custom_entry/.gitignore
@@ -0,0 +1 @@
+results.log
diff --git a/tests/test_cases/test_custom_entry/Makefile b/tests/test_cases/test_custom_entry/Makefile
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_custom_entry/Makefile
@@ -0,0 +1,9 @@
+export PYTHONPATH := .
+export PYGPI_ENTRY_POINT := custom_entry:entry_func
+
+.PHONY: override_for_this_test
+override_for_this_test:
+	-$(MAKE) all
+	python -c 'assert open("results.log").readlines() == open("expected_results.log").readlines()'
+
+include ../../designs/sample_module/Makefile
diff --git a/tests/test_cases/test_custom_entry/custom_entry.py b/tests/test_cases/test_custom_entry/custom_entry.py
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_custom_entry/custom_entry.py
@@ -0,0 +1,13 @@
+from typing import List
+
+from cocotb.log import _filter_from_c, _log_from_c  # noqa: F401
+
+
+def entry_func(argv: List[str]) -> None:
+    with open("results.log", "w") as file:
+        print("got entry", file=file)
+
+
+def _sim_event(level: int, message: str) -> None:
+    with open("results.log", "a") as file:
+        print(f"got event level={level} message={message}", file=file)
diff --git a/tests/test_cases/test_custom_entry/expected_results.log b/tests/test_cases/test_custom_entry/expected_results.log
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_custom_entry/expected_results.log
@@ -0,0 +1,2 @@
+got entry
+got event level=2 message=Simulator shutdown prematurely