Skip to content

VHDL to Python interface: python_pkg on a native bridge for NVC, GHDL and Questa - #1220

Open
ru551n wants to merge 18 commits into
VUnit:masterfrom
ru551n:master
Open

VHDL to Python interface: python_pkg on a native bridge for NVC, GHDL and Questa#1220
ru551n wants to merge 18 commits into
VUnit:masterfrom
ru551n:master

Conversation

@ru551n

@ru551n ru551n commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR brings the VHDL-to-Python interface of the python_pkg branch (Lars Asplund, VUnit/vunit@22af9848) to master, with a new native implementation underneath and a few additions to its API.

The API of the python_pkg branch is the interface. python_pkg.vhd, python_context.vhd, the VHPI package and its C sources, run_script_path, the check_result_t getters and the test cases of tb_python_pkg.vhd are taken over; the testbench's 8 error demonstrations, which failed by design, are now negative tests checking the reported message through the mocked logger, so the whole testbench passes on NVC, GHDL and Questa.

Enable with vu.add_vhdl_builtins() followed by vu.add_python(); in VHDL, context vunit_lib.python_context;. add_python() builds whatever the simulator needs under the output path, so a run script needs nothing else, and the interpreter starts on first use, so python_setup/python_cleanup are optional.

What is underneath

  • NVC and GHDL: the two VHPIDIRECT applications of the branch and their compile_* run-script steps are replaced by the VUnit Python bridge (vunit/python_bridge, C in native/): compiled on first use on Linux/macOS and cached by a fingerprint of its inputs; prebuilt MSVC DLLs per CPython version on Windows (built in CI, shipped in releases, never committed). Loading is automatic (--load for NVC, the search path for GHDL, per backend).
  • Questa/ModelSim: the same bridge through an FLI front end (native/fli.c), built and cached by add_python() like the others (the system C compiler on Linux, the MinGW compiler bundled with Questa on Windows), so the API behaves identically on NVC, GHDL and Questa. Verified on Questa Altera Starter FPGA Edition 2025.3 (Linux). The branch's FLI application is therefore not included.
  • Riviera-PRO/Active-HDL: the branch's VHPI application, now built by add_python() as well and starting the interpreter on first use. Untested (no licence available); its run-script helper compile_vhpi_application is not needed and not included.
  • The interpreter is started with PyConfig as the Python running VUnit (virtual environments and editable installs resolve), the GIL is taken on every entry, only scalars and fixed-size chunks cross the FFI (no length limit, same ABI on all simulators), and Python errors are reported through python_logger (vunit_lib:python), so they can be tested with mock/check_only_log.

Added to the API, and why

  • Sessions: a trailing, defaulted session parameter on every operation; a session is a VUnit object (a record around an integer_vector_ptr_t, created with new_session(name) or, from an existing identity, new_session(id), with get_id/name accessors) whose name is an identity under vunit_lib:python; every session logs on the logger of its identity, python_logger being the parent. Two Python models that both define model or config can live in separate namespaces of the same interpreter.
  • More arg/kwarg value types: std_ulogic (→ bool), arg_unsigned/arg_signed and their kwarg forms (any width, exact Python integers), real_vector, integer_vector_ptr_t, integer_array_t. The typed names for unsigned/signed exist because an arg overload would make a string literal argument such as arg("Hello") ambiguous. A value that cannot be converted (metavalues) is reported on python_logger and becomes an argument that raises in Python, so a call never proceeds with a missing argument. They came out of migrating a real testbench that hands 32-bit CSR counters to a Python checker.
  • Argument groups: kwarg("a", 1) & kwarg("b", 2) becomes one argument (**dict(...)), arg(1) & arg(2) becomes *(1, 2,), and mixed groups follow Python's rules; this lifts the 10-argument limit of call and lets arguments be built in loops (null_arg is the identity), without touching arg_t or to_call_str.
  • integer_array_t as NumPy arrays, in both directions, with shape and word size preserved (get(a, x, y) is a[y, x]): bulk data such as images and weight tensors are not viable as source text.
  • More result types: eval_boolean/call_boolean, eval_std_ulogic, width-checked std_ulogic_vector/signed/unsigned results, call_string, call_real_vector, call_integer_vector_ptr, eval_integer_array/call_integer_array.
  • exec_file: executes a Python file (a relative name is relative to the testbench directory, tb_path) into the session namespace, so a verification component can load its own model from a generic without the testbench knowing that Python is involved.
  • The string-argument forms of to_call_str and call are removed; arguments are always built with arg/kwarg.
  • real values are not range-checked against single precision on the bridge (VHDL real is a double on these simulators); any finite value is exchanged exactly and a non-finite one is reported as a failure.

The embedded Python example gained cases for each of these, in the style of the existing ones, including a minimal verification component whose behaviour is a Python function.

NumPy as a dependency

numpy is added to the package dependencies. integer_array_t values are exchanged as NumPy arrays and a user of the feature should not have to discover that from an error message; the runtime itself only imports NumPy lazily, so nothing changes for users who never call add_python().

Tests and CI

  • vunit/vhdl/python/run.py: the branch's tb_python_pkg.vhd plus tb_python_pkg_bridge.vhd (75 cases for the additions), on NVC, GHDL (mcode, llvm-jit, llvm, gcc) and Questa.
  • python_bridge.yml: Linux with CPython 3.10/3.14 × NVC 1.22.1 and every GHDL backend on the latest release; canaries (allowed to fail) on NVC built from master and the GHDL nightly builds; macOS with NVC and GHDL; the MSVC DLL build (reusable python_bridge_dlls.yml) and DLL-fed Windows tests on NVC and GHDL with CPython 3.10/3.12/3.14; a packaging check. push.yml only gains the DLL download in the release job.
  • Unit tests for the build/cache logic, the generated packages and the simulator hooks; documentation in docs/python_bridge/user_guide.rst.

Review

The first review round is addressed in one commit per comment (24035be91ff756, sessions completed in 6f34c38), each answered on its thread.

Disclosure

This work was done with substantial use of an AI assistant (Claude Code, Anthropic): the design, the C, Python and VHDL code, the tests, the workflows and this description were produced with it, under my direction and review, and verified by running the test suites on the simulators listed above. Please review it with that in mind.

@ru551n
ru551n marked this pull request as ready for review September 13, 2026 12:32
run_script_path(runner_cfg) gives a testbench the path of the run script,
passed by the test runner through the "run script path" key of runner_cfg.
check_result_t gets the accessors is_pass, get_checker, get_msg,
get_log_level, get_line_num and get_file_name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ru551n
ru551n force-pushed the master branch 2 times, most recently from 3c2c338 to badf7eb Compare September 13, 2026 13:01
ru551n and others added 4 commits September 13, 2026 15:17
VHDL testbenches execute Python code and call Python functions through
python_pkg (exec, eval, call with arg and kwarg, sessions, files, NumPy
arrays for integer_array_t), enabled with add_python() after
add_vhdl_builtins() and used through the python_context context.

The implementation for NVC, GHDL and Questa/ModelSim is the Python bridge in
vunit/python_bridge: a C library embedding CPython, called through
VHPIDIRECT or the FLI, compiled on first use on Linux and macOS and cached
under the output path, and shipped as prebuilt MSVC DLLs per CPython
version on Windows. Riviera-PRO/Active-HDL use a VHPI application built
the same way. Python errors are reported through the python_logger logger.

NumPy becomes a dependency of VUnit, since integer_array_t values are
exchanged as NumPy arrays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A testbench exercising python_pkg: executing code, evaluating expressions,
calling functions with positional and keyword arguments, sessions, files,
NumPy arrays, result types, error reporting, and a verification component
whose behaviour is a Python function. Tests needing Python packages that
VUnit does not depend on, and tests demonstrating error reporting, carry
attributes so that they can be left out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
python_bridge.yml tests the feature with CPython from python.org on Linux
(NVC and every GHDL backend, with canaries on NVC built from master and the
GHDL nightly builds), macOS and Windows, checks the package content, and
builds the Windows DLLs with MSVC through the reusable
python_bridge_dlls.yml, which the release job of push.yml also uses so that
releases ship the DLLs while the repository holds no binaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@LarsAsplund LarsAsplund left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not done reviewing but this is a start. As a general comment it looks very good with nice additions. I do see that this will be a feature with a lot of development going forward. For that reason, I think most of it should be external to the VUnit core, for example in repo vunit/python_bridge. That way it can have its own life-cycle with more frequent major releases. I have started an embryo of a package system for VUnit that should be used. See how json4vhdl became a VUnit package that can be downloaded from pypi and imported as Python package (https://pypi.org/project/vunit-json-for-vhdl)

Comment thread docs/news.d/+python_bridge.feature.rst Outdated
@@ -0,0 +1,13 @@
VHDL testbenches can now execute Python code and call Python functions, for

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feel like this should be re-written to cover the larger picture. It feels like Claude (?) focused on the additions such as std_logic support.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 24035be: rewritten as an announcement of the feature itself.

-- module device_model.py, next to this testbench
procedure import_device_model is
begin
import_module_from_file(tb_path(runner_cfg) & "device_model.py", "device_model");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VUnit provides join(a, b) to concatenate path segments in a way that works regardless if the path separator is present in a or not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b9430da: join(tb_path(runner_cfg), "...") everywhere in the example.

check_true(call_boolean("device_model.is_busy"));

-- A single bit and a vector of bits, std_logic characters on the Python side
check_equal(call_std_logic("device_model.parity"), '1', result("for the parity of the status"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other "typed" VUnit functions/procedures use _std_ulogic(_vector)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7a4009: eval_std_ulogic, eval_std_ulogic_vector, call_std_ulogic, call_std_ulogic_vector and arg/kwarg(std_ulogic), with std_ulogic/std_ulogic_vector parameter and result types.


-- exec_file executes the file in the namespace we have been using all along,
-- which puts counter and bump there. The file name is relative to the run script.
exec_file("bump.py");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be explicit rather than implicit regarding the path. That follows the pattern we've used before. That way we don't have to decide what the best root is. tb_path is probably equally good as default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4ce146e: a relative file name is resolved from tb_path (in VHDL, from runner_state), an absolute path is used as given, and the run-script-relative resolution is gone.

check_equal(integer'(call("bump")), 1, result("for the counter after re-executing the file"));

-- import_module_from_file gives us the module object, which keeps its state
import_module_from_file(tb_path(runner_cfg) & "bump.py", "bumper");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

join

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b9430da.

Comment thread vunit/vhdl/python/src/python_pkg.vhd Outdated
"", arg1), arg2), arg3), arg4), arg5), arg6), arg7), arg8), arg9), arg10) & ")";
end;

procedure call(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call with string arguments should be remove. This was my initial approach

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4d5c399: both string forms are removed; the example uses arg(...).

Comment thread vunit/vhdl/python/src/python_pkg.vhd Outdated
function arg(value : real_vector) return arg_t is
constant text : string := p_arg_value(value, "arg");
begin
if text = "" then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an error instead? If the user calls arg and it becomes a null_arg, it will completely disappear from the Python call

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and fixed in d33a067: after the failure is logged, the argument becomes __vunit__.error("<message>"), so the call raises in Python with the same message instead of proceeding with a missing argument (only observable when the logger is mocked, but that is exactly when it matters).

Comment thread vunit/vhdl/python/src/python_pkg.vhd Outdated
end;

-----------------------------------------------------------------------------
-- Keyword argument groups

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can positional arguments be grouped in the same way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Done in e4ffa82: arg(1) & arg(2) becomes one argument *(1, 2,), a mixed group arg(1) & kwarg("a", 2) becomes *(1,), **dict(a=2), and appending a positional argument after keyword arguments is an error, as in Python.

Comment thread vunit/vhdl/python/src/python_pkg.vhd Outdated

impure function "&"(l, r : arg_t) return arg_t is
begin
if l.name = p_ignore_arg then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is grouping will null_arg a real valid use case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is the identity element of &, and three things rely on it: (1) building a group in a loop, args := null_arg; for i in regs'range loop args := args & kwarg(names(i).all, regs(i)); end loop;, which otherwise needs a special first iteration; (2) optional arguments, since VHDL has no conditional expression the idiom is a helper that returns null_arg when the argument does not apply, and the caller just writes base & optional_kwarg(...); (3) an empty group, call("f", args) with args = null_arg, is a valid call with no arguments, consistent with the unused slots of call. It also costs nothing: after the change in d33a067 a failed conversion no longer degrades to null_arg, so it is only ever produced deliberately. Documented in the comment and the user guide in 9e0cdfd.

-- constant golden_model : python_session_t := "golden_model";
--
-- The default session is the __main__ namespace.
type python_session_t is array (positive range <>) of character;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session_t should probably be an object that we can build upon, i.e. a record type with a p_data field of type integer_vector_ptr_t. It is created with new_session function. I would like to see that its name is an identity (id_t) and that log calls are made to a logger with that identity. Going forward, I think we may see message passing into actors within the session namespaces. If so, actors in that namespace will be children of the session identiy. As an initial step, it would be sufficient to place the session name in such an record type but leave everything else as is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 91ff756: python_session_t is a record with p_data : integer_vector_ptr_t, created with new_session(name); the name is an identity under vunit_lib:python (the identity of python_logger), name(session) returns it, and the operations of a session log on the logger of that identity, a child of python_logger (the default session logs on python_logger itself). Sessions are compared by identity, so new_session("golden") twice is the same session.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now exactly as you describe, in 6f34c38: python_session_t is a record with p_data : integer_vector_ptr_t; new_session(name) creates its identity under vunit_lib:python and, following new_actor, new_session(id : id_t) takes an existing identity so that a session can sit anywhere in the identity tree (and actors can be its children later); get_id(session) and name(session) are the accessors; and every session, the default one included, logs on get_logger(get_id(session)) — the earlier exception where the default session logged on python_logger is gone; python_logger is the logger of the parent identity. The Python namespace is keyed by the full identity name, so sessions with the same leaf name under different parents are distinct.

ru551n and others added 12 commits September 14, 2026 10:04
Review: The release note should tell the larger picture, that VHDL
testbenches can execute Python code and call Python functions, rather
than list individual type names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: VUnit provides join(a, b) from the path package, already in
vunit_context, to concatenate path segments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: The rest of VUnit's typed subprograms use _std_ulogic(_vector),
so eval_std_logic and friends should be eval_std_ulogic. The result and
parameter types become std_ulogic/std_ulogic_vector as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: exec_file should not resolve a relative file name against the
run script directory. python_pkg resolves it explicitly against
tb_path, the directory of the testbench file, like the file names of
the other VUnit subprograms, and an absolute name is used as given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: The generated arg and kwarg overloads belong in the arg and
kwarg block of the package rather than in sections of their own further
down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: The p_arg_value overloads whose conversion cannot fail have no
use for the name of the operation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: ieee.std_logic_1164.is_x already has the semantics of the local
p_has_metavalue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: to_call_str and call should take the arg_t arguments that arg
and kwarg build, not strings the caller has converted itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: An arg or kwarg whose value cannot be converted returned
null_arg, which made the call go ahead without the argument once
python_logger was mocked. It now returns an argument whose Python
source text raises the message that was reported, through the new
__vunit__.error of the bridge runtime. to_call_str builds the call
string in VHDL rather than through Python, which keeps the quotes of
such a message intact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: & should not be limited to keyword arguments. A group of
positional arguments becomes *(1, 2,), a group of both kinds
*(1,), **dict(a=1), and appending a positional argument to a group that
already has keyword arguments is an error, like in Python.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: The comment above & and the user guide should explain what the
identity is good for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review: A session should be a VUnit object with an identity rather than
a string. new_session(name) creates it from an identity under
vunit_lib:python, name(session) gives the name back, and the errors of
an operation are reported on the logger of its session. Mocking a
logger does not capture what its children log, so the default session
keeps reporting on python_logger itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ru551n

ru551n commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

I'm not done reviewing but this is a start. As a general comment it looks very good with nice additions. I do see that this will be a feature with a lot of development going forward. For that reason, I think most of it should be external to the VUnit core, for example in repo vunit/python_bridge. That way it can have its own life-cycle with more frequent major releases. I have started an embryo of a package system for VUnit that should be used. See how json4vhdl became a VUnit package that can be downloaded from pypi and imported as Python package (https://pypi.org/project/vunit-json-for-vhdl)

Regarding the packaging, I have created #1221 as a means to support passing simulator flags (hooks into sim_if) as well as running setup hooks (compiling the C python bridge).

I have also created vunit-python-bridge which will be a package similar to vunit-json-for-vhdl, We will merge this later to a repo under the VUnit banner when this feature is completed.

For the sake of context I propose that we keep reviewing here, then sync to the vunit-python-bridge repo when the review is done. When we are satisified we can create a repo under the VUnit group and merge my personal repo to there.

@ru551n

ru551n commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Regarding docs, this is quite complex feature, and a user manual would be nice (in the scope as we already have now). The question is though where it should live. Either it can live here, with an added maintanability issue (syncing with the package repo), or it can live in it's own package repo and get linked to from VUnit main repo.

Maybe add a list of available packages and links to their respective documentation?

As per discussion, as much as possible should reside in the package itself.

@ru551n

ru551n commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Also I feel that the examples should be in the VUnit core. They should fail with an exact error message if the package is not installed.

As per discussion, move examples to package.

An operation now reports on the logger of the identity of the session it was
performed in, get_logger(get_id(session)), the default session included. The
default session used to be a special case reporting on python_logger itself,
which made it the only session whose failures were caught without mocking a
logger of its own. python_logger is now the logger of the parent identity of
the sessions created from a name, so log levels and log handler settings made
on it still apply to all of them, and it is used for what has no session: an
argument value that cannot be converted and the transfer of an
integer_array_t.

A session also follows the conventions of the other VUnit objects, as actors
do: the private p_id is now the public get_id, and new_session(id) takes an
identity that already exists, which puts a session anywhere in the identity
tree rather than only under vunit_lib:python. The full name of the identity is
the key of the Python namespace, so two sessions with the same name but
different identities are namespaces of their own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants