From 7c6271cb3ea842f95cfa22df1d244a7b3dc9e706 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Sun, 13 Sep 2026 14:53:38 +0200 Subject: [PATCH 01/28] Add run_script_path to runner_cfg and result accessors to check_pkg 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 --- docs/news.d/+check_result_getters.feature.rst | 3 ++ docs/news.d/+run_script_path.feature.rst | 2 ++ .../artificial/vhdl/tb_magic_paths.vhd | 1 + tests/unit/test_test_runner.py | 18 +++++------ vunit/test/runner.py | 6 +++- vunit/test/suites.py | 7 +++-- vunit/ui/__init__.py | 25 ++++++++++++++++ vunit/vhdl/check/src/check.vhd | 30 +++++++++++++++++++ vunit/vhdl/check/src/check_api.vhd | 6 ++++ vunit/vhdl/check/test/tb_check.vhd | 6 ++++ vunit/vhdl/run/src/run.vhd | 11 +++++++ vunit/vhdl/run/src/run_api.vhd | 4 +++ 12 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 docs/news.d/+check_result_getters.feature.rst create mode 100644 docs/news.d/+run_script_path.feature.rst diff --git a/docs/news.d/+check_result_getters.feature.rst b/docs/news.d/+check_result_getters.feature.rst new file mode 100644 index 000000000..12ff4359c --- /dev/null +++ b/docs/news.d/+check_result_getters.feature.rst @@ -0,0 +1,3 @@ +Added accessor functions to ``check_pkg`` for inspecting a ``check_result_t``: +``is_pass``, ``get_checker``, ``get_msg``, ``get_log_level``, ``get_line_num`` and +``get_file_name``. diff --git a/docs/news.d/+run_script_path.feature.rst b/docs/news.d/+run_script_path.feature.rst new file mode 100644 index 000000000..adf2583de --- /dev/null +++ b/docs/news.d/+run_script_path.feature.rst @@ -0,0 +1,2 @@ +Added ``run_script_path(runner_cfg)`` to ``run_pkg``, returning the path of the +VUnit run script that started the simulation. diff --git a/tests/acceptance/artificial/vhdl/tb_magic_paths.vhd b/tests/acceptance/artificial/vhdl/tb_magic_paths.vhd index 387ab7824..752cd50c6 100644 --- a/tests/acceptance/artificial/vhdl/tb_magic_paths.vhd +++ b/tests/acceptance/artificial/vhdl/tb_magic_paths.vhd @@ -25,6 +25,7 @@ begin test_runner_setup(runner, runner_cfg); check_has_suffix(tb_path, "/tests/acceptance/artificial/vhdl/"); check_has_suffix(vunit_lib.run_pkg.tb_path(runner_cfg), "/tests/acceptance/artificial/vhdl/"); + check_has_suffix(vunit_lib.run_pkg.run_script_path(runner_cfg), "/run.py"); test_runner_cleanup(runner); wait; end process; diff --git a/tests/unit/test_test_runner.py b/tests/unit/test_test_runner.py index 07afec9f2..48f2692e2 100644 --- a/tests/unit/test_test_runner.py +++ b/tests/unit/test_test_runner.py @@ -26,7 +26,7 @@ class TestTestRunner(unittest.TestCase): @with_tempdir def test_runs_testcases_in_order(self, tempdir): report = TestReport() - runner = TestRunner(report, tempdir) + runner = TestRunner(report, tempdir, Path("run.py")) order = [] test_case1 = self.create_test("test1", True, order=order) @@ -55,7 +55,7 @@ def test_runs_testcases_in_order(self, tempdir): @with_tempdir def test_fail_fast(self, tempdir): report = TestReport() - runner = TestRunner(report, tempdir, fail_fast=True) + runner = TestRunner(report, tempdir, Path("run.py"), fail_fast=True) order = [] test_case1 = self.create_test("test1", True, order=order) @@ -84,7 +84,7 @@ def test_fail_fast(self, tempdir): @with_tempdir def test_handles_python_exeception(self, tempdir): report = TestReport() - runner = TestRunner(report, tempdir) + runner = TestRunner(report, tempdir, Path("run.py")) test_case = self.create_test("test", True) test_list = TestList() @@ -103,7 +103,7 @@ def side_effect(*args, **kwargs): # pylint: disable=unused-argument @with_tempdir def test_collects_output(self, tempdir): report = TestReport() - runner = TestRunner(report, tempdir) + runner = TestRunner(report, tempdir, Path("run.py")) test_case = self.create_test("test", True) test_list = TestList() @@ -129,7 +129,7 @@ def side_effect(*args, **kwargs): # pylint: disable=unused-argument @with_tempdir def test_can_read_output(self, tempdir): report = TestReport() - runner = TestRunner(report, tempdir) + runner = TestRunner(report, tempdir, Path("run.py")) test_case = self.create_test("test", True) test_list = TestList() @@ -159,7 +159,7 @@ def side_effect(read_output, **kwargs): # pylint: disable=unused-argument def test_get_output_path_on_linux(self): output_path = "output_path" report = TestReport() - runner = TestRunner(report, output_path) + runner = TestRunner(report, output_path, Path("run.py")) with mock.patch("sys.platform", new="linux"): with mock.patch("os.environ", new={}): @@ -190,7 +190,7 @@ def test_get_output_path_on_linux(self): def test_get_output_path_on_windows(self): output_path = "output_path" report = TestReport() - runner = TestRunner(report, output_path) + runner = TestRunner(report, output_path, Path("run.py")) with mock.patch("sys.platform", new="win32"): with mock.patch("os.environ", new={}): @@ -247,7 +247,7 @@ def __init__(self, name, run_side_effect): self.called = False self.run_side_effect = run_side_effect - def run(self, output_path, read_output): + def run(self, output_path, read_output, run_script_path): """ Mock run method that just records the arguments """ @@ -255,7 +255,7 @@ def run(self, output_path, read_output): self.called = True self.output_path = output_path self.read_output = read_output - return self.run_side_effect(output_path=output_path, read_output=read_output) + return self.run_side_effect(output_path=output_path, read_output=read_output, run_script_path=run_script_path) def get_seed(self): return "0123456789abcdef" diff --git a/vunit/test/runner.py b/vunit/test/runner.py index 4f50c1147..0cc986159 100644 --- a/vunit/test/runner.py +++ b/vunit/test/runner.py @@ -39,6 +39,7 @@ def __init__( # pylint: disable=too-many-arguments self, report, output_path, + run_script_path, *, verbosity=VERBOSITY_NORMAL, num_threads=1, @@ -54,6 +55,7 @@ def __init__( # pylint: disable=too-many-arguments self._local = threading.local() self._report = report self._output_path = output_path + self._run_script_path = run_script_path assert verbosity in ( self.VERBOSITY_QUIET, self.VERBOSITY_NORMAL, @@ -251,7 +253,9 @@ def read_output(): output_file.seek(prev) return contents - results = test_suite.run(output_path=output_path, read_output=read_output) + results = test_suite.run( + output_path=output_path, read_output=read_output, run_script_path=self._run_script_path + ) except KeyboardInterrupt as exk: self._add_skipped_tests(test_suite, results, start_time, num_tests, output_file_name) raise KeyboardInterrupt from exk diff --git a/vunit/test/suites.py b/vunit/test/suites.py index e1b5a27ec..3d0c17472 100644 --- a/vunit/test/suites.py +++ b/vunit/test/suites.py @@ -201,7 +201,7 @@ def get_seed(self): def set_test_cases(self, test_cases): self._test_cases = test_cases - def run(self, output_path, read_output): + def run(self, output_path, read_output, run_script_path): """ Run selected test cases within the test suite @@ -218,7 +218,7 @@ def run(self, output_path, read_output): # Ensure result file exists ostools.write_file(get_result_file_name(output_path), "") - sim_ok = self._simulate(output_path) + sim_ok = self._simulate(output_path, run_script_path) if self._elaborate_only: status = PASSED if sim_ok else FAILED @@ -254,7 +254,7 @@ def _check_results(self, results, sim_ok): return False, results - def _simulate(self, output_path): + def _simulate(self, output_path, run_script_path): """ Add runner_cfg generic values and run simulation """ @@ -275,6 +275,7 @@ def _simulate(self, output_path): "output path": output_path.replace("\\", "/") + "/", "active python runner": True, "tb path": config.tb_path.replace("\\", "/") + "/", + "run script path": str(run_script_path).replace("\\", "/"), "seed": seed, } diff --git a/vunit/ui/__init__.py b/vunit/ui/__init__.py index da1bbfcf4..c0b87eaea 100644 --- a/vunit/ui/__init__.py +++ b/vunit/ui/__init__.py @@ -12,6 +12,7 @@ import csv import sys +import inspect import traceback import logging import json @@ -49,6 +50,28 @@ from .results import Results +def _find_run_script_path() -> Path: + """ + Return the path of the run script: the file of the first call-stack frame + whose file is not inside the vunit package directory. + """ + vunit_package_dir = Path(__file__).resolve().parent.parent + for frame_info in inspect.stack(0): + candidate = Path(frame_info.filename) + if not candidate.is_file(): + continue + candidate = candidate.resolve() + try: + candidate.relative_to(vunit_package_dir) + except ValueError: + return candidate + + if Path(sys.argv[0]).is_file(): + return Path(sys.argv[0]).resolve() + + return Path.cwd() + + class VUnit(object): # pylint: disable=too-many-instance-attributes, too-many-public-methods """ The public interface of VUnit @@ -140,6 +163,7 @@ def __init__( self._args = args self._configure_logging(args.log_level) self._output_path = str(Path(args.output_path).resolve()) + self._run_script_path = _find_run_script_path() if args.no_color: self._printer = NO_COLOR_PRINTER @@ -1232,6 +1256,7 @@ def _run_test(self, test_cases, report, simulator_if): runner = TestRunner( report, str(Path(self._output_path) / TEST_OUTPUT_PATH), + self._run_script_path, verbosity=verbosity, num_threads=self._args.num_threads, fail_fast=self._args.fail_fast, diff --git a/vunit/vhdl/check/src/check.vhd b/vunit/vhdl/check/src/check.vhd index e45cbb194..084e07cf9 100644 --- a/vunit/vhdl/check/src/check.vhd +++ b/vunit/vhdl/check/src/check.vhd @@ -131,6 +131,36 @@ package body check_pkg is log(check_result); end; + impure function is_pass(check_result : check_result_t) return boolean is + begin + return check_result.p_is_pass; + end; + + impure function get_checker(check_result : check_result_t) return checker_t is + begin + return check_result.p_checker; + end; + + impure function get_msg(check_result : check_result_t) return string is + begin + return to_string(check_result.p_msg); + end; + + impure function get_log_level(check_result : check_result_t) return log_level_t is + begin + return check_result.p_level; + end; + + impure function get_line_num(check_result : check_result_t) return natural is + begin + return check_result.p_line_num; + end; + + impure function get_file_name(check_result : check_result_t) return string is + begin + return to_string(check_result.p_file_name); + end; + ----------------------------------------------------------------------------- -- check ----------------------------------------------------------------------------- diff --git a/vunit/vhdl/check/src/check_api.vhd b/vunit/vhdl/check/src/check_api.vhd index bfe3c5674..e9259793d 100644 --- a/vunit/vhdl/check/src/check_api.vhd +++ b/vunit/vhdl/check/src/check_api.vhd @@ -45,6 +45,12 @@ package check_pkg is procedure log(check_result : check_result_t); procedure notify_if_fail(check_result : check_result_t; signal event : inout any_event_t); + impure function is_pass(check_result : check_result_t) return boolean; + impure function get_checker(check_result : check_result_t) return checker_t; + impure function get_msg(check_result : check_result_t) return string; + impure function get_log_level(check_result : check_result_t) return log_level_t; + impure function get_line_num(check_result : check_result_t) return natural; + impure function get_file_name(check_result : check_result_t) return string; ----------------------------------------------------------------------------- -- check diff --git a/vunit/vhdl/check/test/tb_check.vhd b/vunit/vhdl/check/test/tb_check.vhd index 56e69019b..6e0c4f80c 100644 --- a/vunit/vhdl/check/test/tb_check.vhd +++ b/vunit/vhdl/check/test/tb_check.vhd @@ -216,6 +216,10 @@ begin check_result := internal_check(true); assert_true(check_result.p_is_pass, "Should return check_result.is_pass = true on passing check"); assert_true(check_result.p_checker = default_checker); + assert_true(is_pass(check_result), "Should return is_pass = true on passing check"); + assert_true(get_checker(check_result) = default_checker); + assert_true(get_line_num(check_result) = check_result.p_line_num); + assert_true(get_file_name(check_result) = to_string(check_result.p_file_name)); verify_passed_checks(stat, 4); get_checker_stat(check_checker, stat); @@ -236,6 +240,8 @@ begin check_result := internal_check(true); assert_true(to_string(check_result.p_msg) = prefix & "passed.", "Got : " & to_string(check_result.p_msg)); assert_true(check_result.p_level = pass); + assert_true(get_msg(check_result) = to_string(check_result.p_msg)); + assert_true(get_log_level(check_result) = pass); internal_check(true, ""); check_only_log(check_logger, "", pass); diff --git a/vunit/vhdl/run/src/run.vhd b/vunit/vhdl/run/src/run.vhd index b2675c2f7..0e6da535b 100644 --- a/vunit/vhdl/run/src/run.vhd +++ b/vunit/vhdl/run/src/run.vhd @@ -625,4 +625,15 @@ package body run_pkg is end if; end; + impure function run_script_path( + constant runner_cfg : string) + return string is + begin + if has_key(runner_cfg, "run script path") then + return get(runner_cfg, "run script path"); + else + return ""; + end if; + end; + end package body run_pkg; diff --git a/vunit/vhdl/run/src/run_api.vhd b/vunit/vhdl/run/src/run_api.vhd index 515691cfd..8b641f25e 100644 --- a/vunit/vhdl/run/src/run_api.vhd +++ b/vunit/vhdl/run/src/run_api.vhd @@ -180,6 +180,10 @@ package run_pkg is constant runner_cfg : string) return string; + impure function run_script_path( + constant runner_cfg : string) + return string; + alias test_runner_setup_entry_gate is entry_gate[runner_sync_t]; alias test_runner_setup_exit_gate is exit_gate[runner_sync_t]; alias test_suite_setup_entry_gate is entry_gate[runner_sync_t]; From bf150534524d8d06d98f297977cb547d9876ad80 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Sun, 13 Sep 2026 14:53:50 +0200 Subject: [PATCH 02/28] Add a VHDL to Python interface: python_pkg on a native bridge 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 --- pyproject.toml | 3 +- setup.py | 1 + tests/unit/test_python_bridge.py | 1016 ++++++++++++++++ vunit/builtins.py | 74 ++ vunit/python_bridge/__init__.py | 23 + vunit/python_bridge/bridge.py | 185 +++ vunit/python_bridge/foreign_application.py | 86 ++ vunit/python_bridge/native/arguments.c | 191 +++ vunit/python_bridge/native/bridge.h | 117 ++ vunit/python_bridge/native/config.c | 199 +++ vunit/python_bridge/native/error.c | 130 ++ vunit/python_bridge/native/fli.c | 98 ++ vunit/python_bridge/native/interpreter.c | 276 +++++ vunit/python_bridge/native/operations.c | 248 ++++ vunit/python_bridge/native_library.py | 380 ++++++ vunit/python_bridge/runtime.py | 701 +++++++++++ vunit/python_bridge/simulator_hooks.py | 72 ++ vunit/sim_if/__init__.py | 7 + vunit/sim_if/activehdl.py | 7 + vunit/sim_if/ghdl.py | 11 +- vunit/sim_if/modelsim.py | 13 + vunit/sim_if/nvc.py | 9 + vunit/sim_if/rivierapro.py | 7 + vunit/sim_if/vsim_simulator_mixin.py | 13 +- vunit/ui/__init__.py | 19 + vunit/vhdl/python/run.py | 94 ++ .../vhdl/python/src/python_bridge_pkg.vhd.in | 196 +++ vunit/vhdl/python/src/python_context.vhd | 13 + .../vhdl/python/src/python_ffi_pkg_bridge.vhd | 432 +++++++ vunit/vhdl/python/src/python_pkg.c | 125 ++ vunit/vhdl/python/src/python_pkg.h | 31 + vunit/vhdl/python/src/python_pkg.vhd | 1064 +++++++++++++++++ vunit/vhdl/python/src/python_pkg_vhpi.c | 366 ++++++ vunit/vhdl/python/src/python_pkg_vhpi.vhd | 220 ++++ vunit/vhdl/python/test/models/counter.py | 18 + vunit/vhdl/python/test/models/filters.py | 14 + vunit/vhdl/python/test/models/importer.py | 29 + .../python/test/models/reference_model.py | 24 + vunit/vhdl/python/test/tb_python_pkg.vhd | 395 ++++++ .../vhdl/python/test/tb_python_pkg_bridge.vhd | 908 ++++++++++++++ .../vhdl/python/tools/generate_python_pkg.py | 392 ++++++ vunit/vhdl/python/tools/python_pkg.vhd.in | 697 +++++++++++ 42 files changed, 8900 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_python_bridge.py create mode 100644 vunit/python_bridge/__init__.py create mode 100644 vunit/python_bridge/bridge.py create mode 100644 vunit/python_bridge/foreign_application.py create mode 100644 vunit/python_bridge/native/arguments.c create mode 100644 vunit/python_bridge/native/bridge.h create mode 100644 vunit/python_bridge/native/config.c create mode 100644 vunit/python_bridge/native/error.c create mode 100644 vunit/python_bridge/native/fli.c create mode 100644 vunit/python_bridge/native/interpreter.c create mode 100644 vunit/python_bridge/native/operations.c create mode 100644 vunit/python_bridge/native_library.py create mode 100644 vunit/python_bridge/runtime.py create mode 100644 vunit/python_bridge/simulator_hooks.py create mode 100644 vunit/vhdl/python/run.py create mode 100644 vunit/vhdl/python/src/python_bridge_pkg.vhd.in create mode 100644 vunit/vhdl/python/src/python_context.vhd create mode 100644 vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd create mode 100644 vunit/vhdl/python/src/python_pkg.c create mode 100644 vunit/vhdl/python/src/python_pkg.h create mode 100644 vunit/vhdl/python/src/python_pkg.vhd create mode 100644 vunit/vhdl/python/src/python_pkg_vhpi.c create mode 100644 vunit/vhdl/python/src/python_pkg_vhpi.vhd create mode 100644 vunit/vhdl/python/test/models/counter.py create mode 100644 vunit/vhdl/python/test/models/filters.py create mode 100644 vunit/vhdl/python/test/models/importer.py create mode 100644 vunit/vhdl/python/test/models/reference_model.py create mode 100644 vunit/vhdl/python/test/tb_python_pkg.vhd create mode 100644 vunit/vhdl/python/test/tb_python_pkg_bridge.vhd create mode 100644 vunit/vhdl/python/tools/generate_python_pkg.py create mode 100644 vunit/vhdl/python/tools/python_pkg.vhd.in diff --git a/pyproject.toml b/pyproject.toml index d135dd713..c5a5b21bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "colorama", + "numpy", "tomli; python_version < '3.11'", ] @@ -46,7 +47,7 @@ include-package-data = false where = ["."] include = [ "vunit", "vunit.com", "vunit.parsing","vunit.parsing.verilog", "vunit.sim_if", - "vunit.test", "vunit.ui" + "vunit.test", "vunit.ui", "vunit.python_bridge" ] [tool.black] diff --git a/setup.py b/setup.py index db93bbc7c..a457160eb 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ def find_all_files(directory, endings=None): DATA_FILES += find_all_files("vunit", endings=[".tcl"]) DATA_FILES += find_all_files(str(Path("vunit") / "vhdl")) DATA_FILES += find_all_files(str(Path("vunit") / "verilog"), endings=[".v", ".sv", ".svh"]) +DATA_FILES += find_all_files(str(Path("vunit") / "python_bridge"), endings=[".c", ".h", ".dll"]) DATA_FILES = [os.path.relpath(file_name, "vunit") for file_name in DATA_FILES] setup( diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py new file mode 100644 index 000000000..4fecbc6ad --- /dev/null +++ b/tests/unit/test_python_bridge.py @@ -0,0 +1,1016 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Test the VHDL-to-Python bridge (vunit/python_bridge) + +These tests must never invoke an HDL simulator. Compiling the small C bridge +library with the system C compiler, and reading/writing files, is fine. +""" + +import os +import re +import shutil +import subprocess +import sys +import unittest +from glob import glob +from pathlib import Path +from unittest import mock + +from vunit.python_bridge import bridge as bridge_setup, foreign_application, native_library, simulator_hooks +from vunit.builtins import Builtins, VHDL_PATH +from vunit.vhdl_standard import VHDLStandard +from vunit.sim_if import SimulatorInterface +from tests.common import create_tempdir + + +class _FakeSimulator: + """ + Minimal stand-in for a simulator interface class, as used directly by + bridge_setup.setup() (name, find_prefix(), determine_backend()). + """ + + def __init__(self, name, backend=None, prefix="prefix"): + self.name = name + self._backend = backend + self._prefix = prefix + + def find_prefix(self): + return self._prefix + + def determine_backend(self, prefix): # pylint: disable=unused-argument + return self._backend + + +def _autospec_simulator(name): + """ + A simulator-interface autospec stand-in for use through Builtins, whose + _add_files()/_add_vhdl_logging() call several other simulator-interface + methods (supports_vhdl_contexts(), supports_vhdl_call_paths(), ...) that + are irrelevant to what these tests check, so let them return truthy mocks. + """ + simulator = mock.create_autospec(SimulatorInterface, instance=True) + simulator.name = name + return simulator + + +class _BridgeKey: + """ + A plain, weak-referenceable object to use as a project key in bridge_setup._BRIDGES. + (A bare ``object()`` cannot be weakly referenced.) + """ + + +# The complete VHPIDIRECT contract between python_bridge_pkg.vhd.in and +# native/*.c. The generated VHDL and the built library must agree on it +# exactly: nothing else may be exported. +EXPECTED_EXPORTS = ( + "vpy_setup", + "vpy_cleanup", + "vpy_buffer_clear", + "vpy_buffer_append", + "vpy_begin", + "vpy_execute", + "vpy_eval", + "vpy_push_array", + "vpy_array_write", + "vpy_stage", + "vpy_result_integer", + "vpy_result_real", + "vpy_result_meta", + "vpy_result_read_string", + "vpy_result_read_integers", + "vpy_result_read_reals", + "vpy_error_length", + "vpy_error_read", +) + + +def _write_run_script(path: Path) -> Path: + """ + A stub run script; its directory is the base of relative Python file names. + """ + path.write_text("# run script stub\n", encoding="utf-8") + return path + + +class TestAddPython(unittest.TestCase): + """ + add_python() wiring: Builtins._add_python()/_add_python_bridge() behind the + ``vu.add_vhdl_builtins(); vu.add_python()`` API. add_vhdl_builtins() is + unconditional (no ``python`` parameter, always adds the unmodified + vunit_context.vhd) and add_python() adds python_context.vhd + python_pkg.vhd + plus, depending on simulator_class.supported_foreign_language_interfaces(): + python_pkg_vhpi.vhd (VHPI) or the Python bridge's generated files (FLI and + VHPIDIRECT_*), with bridge_setup.setup() stubbed so no compilation happens + here. + """ + + def setUp(self): + self.vu = mock.Mock() + self.vu._project = mock.Mock() + self.vu._project._libraries = [] + self.vu._output_path = "/fake/output/path" + self.vu._run_script_path = Path("/fake/run_script/run.py") + self.library_mock = mock.Mock() + + def add_library(name): + self.vu._project._libraries.append(name) + return self.library_mock + + self.vu.add_library.side_effect = add_library + + def _builtins(self, vhdl_standard="2008", simulator=None): + return Builtins(self.vu, VHDLStandard(vhdl_standard), simulator) + + def _added_files(self): + return [Path(call.args[0]) for call in self.library_mock.add_source_file.call_args_list] + + @staticmethod + def _simulator_with_flis(name, flis): + simulator = _autospec_simulator(name) + simulator.supported_foreign_language_interfaces.return_value = flis + return simulator + + def test_add_vhdl_builtins_never_touches_the_bridge(self): + builtins = self._builtins(simulator=_autospec_simulator("nvc")) + with mock.patch("vunit.python_bridge.bridge.setup") as setup_mock: + builtins.add_vhdl_builtins() + setup_mock.assert_not_called() + + added_files = self._added_files() + added_names = {p.name for p in added_files} + self.assertIn("vunit_context.vhd", added_names) + self.assertNotIn("python_pkg.vhd", added_names) + self.assertNotIn("python_context.vhd", added_names) + self.assertIn(VHDL_PATH / "vunit_context.vhd", added_files) + + def test_add_python_before_add_vhdl_builtins_raises(self): + builtins = self._builtins(simulator=_autospec_simulator("nvc")) + with self.assertRaisesRegex(RuntimeError, "add_python\\(\\) requires add_vhdl_builtins\\(\\)"): + builtins.add("python") + + def test_rejects_pre_2008_vhdl(self): + builtins = self._builtins(vhdl_standard="2002", simulator=_autospec_simulator("nvc")) + builtins.add_vhdl_builtins() + with self.assertRaisesRegex(RuntimeError, "vhdl 2008 and later"): + builtins.add("python") + + def test_rejects_simulator_class_none(self): + builtins = self._builtins(simulator=None) + builtins._vhdl_builtins_added = True # pylint: disable=protected-access + with self.assertRaisesRegex(RuntimeError, "no simulator was found"): + builtins.add("python") + + def test_rejects_unsupported_simulator(self): + simulator = self._simulator_with_flis("some_simulator", {"SOME_OTHER_INTERFACE"}) + builtins = self._builtins(simulator=simulator) + builtins.add_vhdl_builtins() + with self.assertRaisesRegex(RuntimeError, "supports none of them") as ctx: + builtins.add("python") + # The message names the interfaces so a user knows what is supported. + self.assertIn("VHPI", str(ctx.exception)) + self.assertIn("FLI", str(ctx.exception)) + self.assertIn("VHPIDIRECT_NVC", str(ctx.exception)) + self.assertIn("VHPIDIRECT_GHDL", str(ctx.exception)) + + def test_vhpi_adds_python_pkg_vhpi_and_builds_the_application(self): + simulator = self._simulator_with_flis("rivierapro", {"VHPI"}) + builtins = self._builtins(simulator=simulator) + builtins.add_vhdl_builtins() + with mock.patch("vunit.python_bridge.bridge.setup") as setup_mock, mock.patch( + "vunit.builtins.setup_vhpi_application" + ) as vhpi_mock: + builtins.add("python") + setup_mock.assert_not_called() + vhpi_mock.assert_called_once_with(builtins._vunit_obj._output_path, simulator) # pylint: disable=protected-access + + src_path = VHDL_PATH / "python" / "src" + added_files = self._added_files() + self.assertIn(src_path / "python_context.vhd", added_files) + self.assertIn(src_path / "python_pkg.vhd", added_files) + self.assertIn(src_path / "python_pkg_vhpi.vhd", added_files) + + def test_application_build_failure_is_reported(self): + simulator = self._simulator_with_flis("rivierapro", {"VHPI"}) + builtins = self._builtins(simulator=simulator) + builtins.add_vhdl_builtins() + with mock.patch("vunit.builtins.setup_vhpi_application", side_effect=RuntimeError("no compiler")), mock.patch( + "vunit.builtins.LOGGER" + ) as logger: + with self.assertRaises(SystemExit): + builtins.add("python") + logger.error.assert_called_once_with("%s", mock.ANY) + + def _check_bridge_files_added(self, simulator): + """ + add_python() adds the generated bridge files, and not the VHPI + application package, for a simulator served by the Python bridge. + """ + builtins = self._builtins(simulator=simulator) + builtins.add_vhdl_builtins() + + fake_bridge = bridge_setup.PythonBridge( + library_file=Path("/fake/cache/libvunit_python_bridge.so"), + vhdl_files=[ + Path("/fake/out/python_bridge/vhdl/python_bridge_pkg.vhd"), + bridge_setup.VHDL_SOURCE_PATH / "python_ffi_pkg_bridge.vhd", + ], + ) + with mock.patch("vunit.python_bridge.bridge.setup", return_value=fake_bridge) as setup_mock: + builtins.add("python") + + # The bridge must be handed the VUnit object's own project, output + # path, simulator class and run script path (the base of relative + # Python file names, shared with import_run_script). + setup_mock.assert_called_once_with( + self.vu._project, self.vu._output_path, simulator, self.vu._run_script_path + ) + + src_path = VHDL_PATH / "python" / "src" + added_files = self._added_files() + self.assertIn(src_path / "python_context.vhd", added_files) + self.assertIn(src_path / "python_pkg.vhd", added_files) + for expected in fake_bridge.vhdl_files: + self.assertIn(expected, added_files) + self.assertNotIn(src_path / "python_pkg_vhpi.vhd", added_files) + + def test_vhpidirect_adds_the_bridge_files(self): + self._check_bridge_files_added(self._simulator_with_flis("nvc", {"VHPIDIRECT_NVC"})) + + def test_fli_adds_the_bridge_files(self): + # Questa/ModelSim is served by the bridge too, through native/fli.c. + self._check_bridge_files_added(self._simulator_with_flis("modelsim", {"FLI"})) + + def test_fli_uses_the_bridge_not_the_vhpi_application(self): + simulator = self._simulator_with_flis("modelsim", {"FLI"}) + builtins = self._builtins(simulator=simulator) + builtins.add_vhdl_builtins() + with mock.patch("vunit.python_bridge.bridge.setup"), mock.patch( + "vunit.builtins.setup_vhpi_application" + ) as vhpi_mock: + builtins.add("python") + vhpi_mock.assert_not_called() + + def test_importing_python_bridge_has_no_side_effects(self): + # Re-importing must not create any files or directories or run any subprocess. + def listing(): + return sorted( + str(path) for path in native_library.PACKAGE_PATH.rglob("*") if "__pycache__" not in str(path) + ) + + before = listing() + with mock.patch("subprocess.run") as run_mock: + import importlib + + for module in (native_library, bridge_setup, simulator_hooks): + importlib.reload(module) + run_mock.assert_not_called() + self.assertEqual(listing(), before) + + +class TestFindRunScriptPath(unittest.TestCase): + """ + vunit.ui._find_run_script_path(): the file of the first call-stack frame + outside the vunit package, used as the VUnit object's ``_run_script_path`` + (fed to the Python bridge and, through run_script_path(runner_cfg), to + import_run_script). + """ + + def test_returns_the_file_of_the_caller_outside_vunit(self): + from vunit.ui import _find_run_script_path # pylint: disable=import-outside-toplevel + + self.assertEqual(_find_run_script_path(), Path(__file__).resolve()) + + +class TestBridgePackageSubstitution(unittest.TestCase): + """ + Library token substitution into the generated python_bridge_pkg.vhd. + """ + + def setUp(self): + self.tempdir_cm = create_tempdir() + self.tempdir = self.tempdir_cm.__enter__() + self.addCleanup(self.tempdir_cm.__exit__, None, None, None) + self.run_script = _write_run_script(self.tempdir / "run.py") + + def _setup(self, simulator, library_file_name="libvunit_python_bridge.so"): + fake_library_file = self.tempdir / "cache" / library_file_name + with mock.patch("vunit.python_bridge.bridge.prepare_library", return_value=fake_library_file): + return bridge_setup.setup(_BridgeKey(), self.tempdir / "out", simulator, self.run_script) + + def _fli_setup(self): + return self._setup(_FakeSimulator("modelsim"), library_file_name="libvunit_python_bridge_fli.so") + + def _ffi_text(self, bridge): + for path in bridge.vhdl_files: + if path.name == "python_bridge_pkg.vhd": + return path.read_text(encoding="utf-8") + self.fail("python_bridge_pkg.vhd not found among bridge.vhdl_files") + return "" + + def test_generated_package_is_the_private_bridge_package(self): + text = self._ffi_text(self._setup(_FakeSimulator("nvc"))) + self.assertIn("package python_bridge_pkg is", text) + self.assertIn("package body python_bridge_pkg is", text) + + def test_exports_match_the_native_library(self): + text = self._ffi_text(self._setup(_FakeSimulator("nvc"))) + declared = set(re.findall(r'VHPIDIRECT \S+ (\w+)"', text)) + self.assertEqual(declared, set(EXPECTED_EXPORTS)) + + def test_token_is_library_file_name_for_nvc(self): + bridge = self._setup(_FakeSimulator("nvc")) + text = self._ffi_text(bridge) + self.assertIn('"VHPIDIRECT libvunit_python_bridge.so vpy_begin"', text) + + def test_token_is_library_file_name_for_ghdl_mcode(self): + bridge = self._setup(_FakeSimulator("ghdl", backend="mcode")) + text = self._ffi_text(bridge) + self.assertIn('"VHPIDIRECT libvunit_python_bridge.so vpy_begin"', text) + + def test_token_is_library_file_name_for_ghdl_llvm_jit(self): + bridge = self._setup(_FakeSimulator("ghdl", backend="llvm-jit")) + text = self._ffi_text(bridge) + self.assertIn('"VHPIDIRECT libvunit_python_bridge.so vpy_begin"', text) + + def test_token_is_link_flag_for_ghdl_llvm(self): + bridge = self._setup(_FakeSimulator("ghdl", backend="llvm")) + text = self._ffi_text(bridge) + self.assertIn('"VHPIDIRECT -lvunit_python_bridge vpy_begin"', text) + + def test_token_is_link_flag_for_ghdl_gcc(self): + bridge = self._setup(_FakeSimulator("ghdl", backend="gcc")) + text = self._ffi_text(bridge) + self.assertIn('"VHPIDIRECT -lvunit_python_bridge vpy_begin"', text) + + def test_no_remaining_placeholder(self): + for bridge in (self._setup(_FakeSimulator("nvc")), self._fli_setup()): + text = self._ffi_text(bridge) + self.assertNotIn("{library}", text) + self.assertNotIn("{foreign:vpy_", text) + + def test_fli_attributes_name_the_wrapper_and_the_library_path(self): + # Questa resolves the absolute path, so the library stays in the cache. + bridge = self._fli_setup() + text = self._ffi_text(bridge) + self.assertIn(f'"fli_vpy_begin {bridge.library_file!s}"', text) + self.assertIn(f'"fli_vpy_result_read_reals {bridge.library_file!s}"', text) + attributes = [line for line in text.splitlines() if "attribute foreign" in line] + self.assertEqual([line for line in attributes if "VHPIDIRECT" in line or "{foreign:" in line], []) + + def test_fli_attributes_cover_every_entry_point(self): + bridge = self._fli_setup() + text = self._ffi_text(bridge) + declared = set(re.findall(r'"fli_(\w+) \S+"', text)) + self.assertEqual(declared, set(EXPECTED_EXPORTS)) + + def test_fli_library_is_the_fli_variant(self): + with mock.patch("vunit.python_bridge.bridge.prepare_library") as prepare_mock: + prepare_mock.return_value = self.tempdir / "cache" / "libvunit_python_bridge_fli.so" + bridge_setup.setup( + _BridgeKey(), self.tempdir / "out", _FakeSimulator("modelsim", prefix="/sim/bin"), self.run_script + ) + # The simulator prefix selects the FLI variant of the library. + self.assertEqual(prepare_mock.call_args.args[1], Path("/sim/bin")) + + def test_no_simulator_prefix_for_vhpidirect(self): + with mock.patch("vunit.python_bridge.bridge.prepare_library") as prepare_mock: + prepare_mock.return_value = self.tempdir / "cache" / "libvunit_python_bridge.so" + bridge_setup.setup(_BridgeKey(), self.tempdir / "out", _FakeSimulator("nvc"), self.run_script) + self.assertIsNone(prepare_mock.call_args.args[1]) + + def test_unsupported_simulator_raises(self): + with self.assertRaisesRegex(RuntimeError, "NVC, GHDL or Questa/ModelSim"): + self._setup(_FakeSimulator("rivierapro")) + + def test_all_vhpidirect_tokens_fit_ghdl_limit(self): + for simulator in ( + _FakeSimulator("nvc"), + _FakeSimulator("ghdl", backend="mcode"), + _FakeSimulator("ghdl", backend="llvm"), + _FakeSimulator("ghdl", backend="gcc"), + ): + bridge = self._setup(simulator) + text = self._ffi_text(bridge) + tokens = re.findall(r'VHPIDIRECT\s+(\S+)\s+\S+"', text) + self.assertTrue(tokens, "no VHPIDIRECT tokens found") + for token in tokens: + self.assertLessEqual(len(token), 32, f"token {token!r} exceeds GHDL's 32 character limit") + self.assertNotIn(" ", token) + + +@unittest.skipIf(sys.platform == "win32", "POSIX build/cache behavior") +class TestPosixBuildAndCache(unittest.TestCase): + """ + Compilation and caching of the native bridge library on Linux/macOS. + + Only a couple of real compiles happen here (gcc + Python headers are + available in this environment); everything else is asserted not to compile. + """ + + def setUp(self): + self.tempdir_cm = create_tempdir() + self.tempdir = self.tempdir_cm.__enter__() + self.addCleanup(self.tempdir_cm.__exit__, None, None, None) + self.run_script = _write_run_script(self.tempdir / "run.py") + + def _setup(self, output_path=None): + return bridge_setup.setup(_BridgeKey(), output_path or self.tempdir / "out", None, self.run_script) + + def test_first_setup_compiles_and_names_library(self): + bridge = self._setup() + self.assertTrue(bridge.library_file.is_file()) + self.assertEqual(bridge.library_file.name, "libvunit_python_bridge.so") + + def test_second_setup_reuses_cache_without_compiling(self): + self._setup() + with mock.patch("subprocess.run") as run_mock: + bridge = self._setup() + run_mock.assert_not_called() + self.assertTrue(bridge.library_file.is_file()) + + def test_changed_source_gets_new_cache_dir_and_recompiles(self): + first = self._setup() + + modified_native = self.tempdir / "native" + shutil.copytree(native_library.NATIVE_PATH, modified_native) + with (modified_native / "error.c").open("a", encoding="utf-8") as fptr: + fptr.write("\n/* test tweak */\n") + + with mock.patch("vunit.python_bridge.native_library.NATIVE_PATH", modified_native): + second = self._setup() + + self.assertNotEqual(first.library_file.parent, second.library_file.parent) + self.assertTrue(second.library_file.is_file()) + + def test_compile_failure_raises_with_compiler_output(self): + fake_proc = mock.Mock(returncode=1, stdout=b"bogus.c:1:1: error: fake failure\n") + with mock.patch("subprocess.run", return_value=fake_proc): + with self.assertRaisesRegex(RuntimeError, "fake failure"): + self._setup() + + def test_missing_python_h_raises_actionable_error(self): + empty_include_dir = self.tempdir / "no_headers" + empty_include_dir.mkdir() + real_get_paths = native_library.sysconfig.get_paths + + def fake_get_paths(): + paths = dict(real_get_paths()) + paths["include"] = str(empty_include_dir) + paths["platinclude"] = str(empty_include_dir) + return paths + + with mock.patch("vunit.python_bridge.native_library.sysconfig.get_paths", side_effect=fake_get_paths): + with self.assertRaisesRegex(RuntimeError, "Python.h"): + self._setup() + + def test_static_only_python_raises(self): + real_get_config_var = native_library.sysconfig.get_config_var + + def fake_get_config_var(name): + if name == "Py_ENABLE_SHARED": + return 0 + return real_get_config_var(name) + + with mock.patch("vunit.python_bridge.native_library.sysconfig.get_config_var", side_effect=fake_get_config_var): + with self.assertRaisesRegex(RuntimeError, "shared Python library"): + self._setup() + + def test_free_threaded_python_raises(self): + real_get_config_var = native_library.sysconfig.get_config_var + + def fake_get_config_var(name): + if name == "Py_GIL_DISABLED": + return 1 + return real_get_config_var(name) + + with mock.patch("vunit.python_bridge.native_library.sysconfig.get_config_var", side_effect=fake_get_config_var): + with self.assertRaisesRegex(RuntimeError, "free-threaded"): + self._setup() + + def test_library_exports_exactly_the_contract(self): + nm = shutil.which("nm") + if nm is None: + self.skipTest("nm is not available") + bridge = self._setup() + proc = subprocess.run( + [nm, "-D", "--defined-only", str(bridge.library_file)], + check=True, + capture_output=True, + text=True, + ) + exported = set() + for line in proc.stdout.splitlines(): + fields = line.split() + if len(fields) >= 3 and fields[-2] in "TtWwDdBb": + exported.add(fields[-1]) + # -fvisibility=hidden plus VPY_EXPORT: only the contract is exported, + # no bridge internals (vpy_initialize, vpy_set_error, ...) leak out. + self.assertEqual(exported, set(EXPECTED_EXPORTS)) + + def _fake_simulator_prefix(self): + """ + A simulator installation with just the FLI header the build needs. + """ + include = self.tempdir / "questa" / "include" + include.mkdir(parents=True) + (include / "mti.h").write_text("/* fake */\n", encoding="utf-8") + prefix = self.tempdir / "questa" / "bin" + prefix.mkdir() + return prefix + + @staticmethod + def _compiler_stub(calls): + """ + Stand in for the C compiler: record the command and create its output. + """ + + def run(cmd, **kwargs): # pylint: disable=unused-argument + calls.append(cmd) + Path(cmd[cmd.index("-o") + 1]).write_bytes(b"") + return mock.Mock(returncode=0, stdout=b"") + + return run + + def test_fli_variant_adds_the_front_end_and_the_simulator_headers(self): + prefix = self._fake_simulator_prefix() + calls = [] + with mock.patch("subprocess.run", side_effect=self._compiler_stub(calls)): + library_file = native_library.prepare_library(self.tempdir / "out", prefix) + self.assertEqual(len(calls), 1) + self.assertEqual(library_file.name, "libvunit_python_bridge_fli.so") + self.assertIn(str(native_library.NATIVE_PATH / "fli.c"), calls[0]) + self.assertIn(f"-I{prefix.parent / 'include'!s}", calls[0]) + + def test_vhpidirect_variant_omits_the_front_end(self): + calls = [] + with mock.patch("subprocess.run", side_effect=self._compiler_stub(calls)): + library_file = native_library.prepare_library(self.tempdir / "out") + self.assertEqual(library_file.name, "libvunit_python_bridge.so") + self.assertNotIn(str(native_library.NATIVE_PATH / "fli.c"), calls[0]) + + def test_fli_and_vhpidirect_variants_are_cached_separately(self): + prefix = self._fake_simulator_prefix() + calls = [] + with mock.patch("subprocess.run", side_effect=self._compiler_stub(calls)): + vhpidirect = native_library.prepare_library(self.tempdir / "out") + fli = native_library.prepare_library(self.tempdir / "out", prefix) + self.assertEqual(len(calls), 2) + self.assertNotEqual(fli.parent, vhpidirect.parent) + + def test_second_fli_setup_reuses_cache_without_compiling(self): + prefix = self._fake_simulator_prefix() + with mock.patch("subprocess.run", side_effect=self._compiler_stub([])): + first = native_library.prepare_library(self.tempdir / "out", prefix) + with mock.patch("subprocess.run") as run_mock: + second = native_library.prepare_library(self.tempdir / "out", prefix) + run_mock.assert_not_called() + self.assertEqual(first, second) + + def test_another_simulator_installation_gets_its_own_cache_dir(self): + first_prefix = self._fake_simulator_prefix() + other = self.tempdir / "other_questa" + (other / "include").mkdir(parents=True) + (other / "include" / "mti.h").write_text("/* fake */\n", encoding="utf-8") + (other / "bin").mkdir() + calls = [] + with mock.patch("subprocess.run", side_effect=self._compiler_stub(calls)): + first = native_library.prepare_library(self.tempdir / "out", first_prefix) + second = native_library.prepare_library(self.tempdir / "out", other / "bin") + self.assertEqual(len(calls), 2) + self.assertNotEqual(first.parent, second.parent) + + def test_missing_mti_h_raises_actionable_error(self): + prefix = self.tempdir / "questa" / "bin" + prefix.mkdir(parents=True) + with self.assertRaisesRegex(RuntimeError, "mti.h"): + native_library.prepare_library(self.tempdir / "out", prefix) + + def test_no_prebuilt_library_in_repository(self): + # Linux libraries are built on first use and Windows DLLs are added to releases by CI + matches = [] + for pattern in ["*.so", "*.dll"]: + matches += glob(str(native_library.PACKAGE_PATH / "**" / pattern), recursive=True) + matches += glob(str(VHDL_PATH / "**" / pattern), recursive=True) + self.assertEqual(matches, []) + + +class TestConfigFile(unittest.TestCase): + """ + _config_text / _write_if_changed + """ + + def test_config_keys_linux(self): + with mock.patch("sys.platform", "linux"): + text = bridge_setup._config_text("/base/dir") # pylint: disable=protected-access + keys = dict(line.split("=", 1) for line in text.splitlines()) + self.assertEqual(set(keys), {"executable", "prefix", "runtime", "base_dir"}) + self.assertEqual(keys["executable"], sys.executable) + self.assertEqual(keys["prefix"], sys.prefix) + self.assertEqual(keys["runtime"], str(bridge_setup.RUNTIME_SOURCE)) + self.assertEqual(keys["base_dir"], "/base/dir") + + def test_config_keys_windows_include_python_dll(self): + with ( + mock.patch("sys.platform", "win32"), + mock.patch("vunit.python_bridge.bridge.windows_python_dll", return_value=r"C:\python.dll"), + ): + text = bridge_setup._config_text("/base/dir") # pylint: disable=protected-access + keys = dict(line.split("=", 1) for line in text.splitlines()) + self.assertEqual(keys["python_dll"], r"C:\python.dll") + + def test_write_if_changed_does_not_rewrite_identical_content(self): + with create_tempdir() as tempdir: + path = tempdir / "file.txt" + bridge_setup._write_if_changed(path, "hello") # pylint: disable=protected-access + mtime_before = path.stat().st_mtime_ns + bridge_setup._write_if_changed(path, "hello") # pylint: disable=protected-access + self.assertEqual(path.stat().st_mtime_ns, mtime_before) + + def test_write_if_changed_rewrites_changed_content(self): + with create_tempdir() as tempdir: + path = tempdir / "file.txt" + bridge_setup._write_if_changed(path, "hello") # pylint: disable=protected-access + bridge_setup._write_if_changed(path, "world") # pylint: disable=protected-access + self.assertEqual(path.read_text(encoding="utf-8"), "world") + + def test_base_dir_is_the_directory_of_the_run_script(self): + with create_tempdir() as tempdir: + run_script = _write_run_script(tempdir / "run.py") + fake_library_file = tempdir / "cache" / "libvunit_python_bridge.so" + with mock.patch("vunit.python_bridge.bridge.prepare_library", return_value=fake_library_file): + bridge_setup.setup(_BridgeKey(), tempdir / "out", None, run_script) + config = (fake_library_file.parent / bridge_setup.CONFIG_FILE_NAME).read_text(encoding="utf-8") + keys = dict(line.split("=", 1) for line in config.splitlines()) + self.assertEqual(keys["base_dir"], str(tempdir.resolve())) + + def test_config_paths_with_spaces_and_unicode_written_as_utf8(self): + with create_tempdir() as tempdir: + weird_dir = tempdir / "weird dir \u00e5\u00e4\u00f6 \u65e5\u672c\u8a9e" + weird_dir.mkdir() + with mock.patch("sys.platform", "linux"): + text = bridge_setup._config_text(str(weird_dir)) # pylint: disable=protected-access + self.assertIn(str(weird_dir), text) + path = tempdir / "cfg" + bridge_setup._write_if_changed(path, text) # pylint: disable=protected-access + self.assertEqual(path.read_text(encoding="utf-8"), text) + + def test_line_break_in_path_raises(self): + with mock.patch("sys.executable", "/usr/bin/py\nthon"): + with self.assertRaisesRegex(RuntimeError, "line breaks"): + bridge_setup._config_text("/base/dir") # pylint: disable=protected-access + + +class TestWindowsDllSelection(unittest.TestCase): + """ + Windows prebuilt-DLL selection logic. Testable on any platform since it + is pure logic + file copying, gated only by explicit patches here (never + by sys.platform inside these helper functions themselves). + """ + + def test_windows_dll_name_for_supported_versions(self): + for minor in range(10, 15): + self.assertEqual( + native_library.windows_dll_name((3, minor)), + f"vunit_python_bridge-cp3{minor}-win_amd64.dll", + ) + + def _prepare(self, tempdir, dll_bytes, version_info=(3, 12)): + binary_path = tempdir / "bin" + binary_path.mkdir(exist_ok=True) + name = native_library.windows_dll_name(version_info) + (binary_path / name).write_bytes(dll_bytes) + root = tempdir / "root" + with ( + mock.patch("vunit.python_bridge.native_library.BINARY_PATH", binary_path), + mock.patch("sys.version_info", version_info), + mock.patch("vunit.python_bridge.native_library.sysconfig.get_platform", return_value="win-amd64"), + mock.patch("subprocess.run") as run_mock, + mock.patch("shutil.which") as which_mock, + ): + target = native_library._prepare_windows_library(root) # pylint: disable=protected-access + run_mock.assert_not_called() + which_mock.assert_not_called() + return target + + def test_copies_selected_dll_as_vunit_python_bridge_dll(self): + with create_tempdir() as tempdir: + target = self._prepare(tempdir, b"fake-dll-content") + self.assertEqual(target.name, "vunit_python_bridge.dll") + self.assertEqual(target.read_bytes(), b"fake-dll-content") + + def test_reuses_existing_file_when_content_unchanged(self): + with create_tempdir() as tempdir: + first = self._prepare(tempdir, b"same-content") + mtime_before = first.stat().st_mtime_ns + second = self._prepare(tempdir, b"same-content") + self.assertEqual(first, second) + self.assertEqual(second.stat().st_mtime_ns, mtime_before) + + def test_new_directory_when_content_changes(self): + with create_tempdir() as tempdir: + first = self._prepare(tempdir, b"version-one") + second = self._prepare(tempdir, b"version-two") + self.assertNotEqual(first.parent, second.parent) + + def test_missing_dll_for_running_version_raises_actionable_error(self): + root = None + with create_tempdir() as tempdir: + binary_path = tempdir / "bin" + binary_path.mkdir() + root = tempdir / "root" + with ( + mock.patch("vunit.python_bridge.native_library.BINARY_PATH", binary_path), + mock.patch("sys.version_info", (3, 12)), + mock.patch("vunit.python_bridge.native_library.sysconfig.get_platform", return_value="win-amd64"), + mock.patch("subprocess.run") as run_mock, + ): + with self.assertRaisesRegex(RuntimeError, "No prebuilt Python bridge DLL"): + native_library._prepare_windows_library(root) # pylint: disable=protected-access + run_mock.assert_not_called() + + def test_non_win_amd64_platform_raises(self): + with mock.patch("vunit.python_bridge.native_library.sysconfig.get_platform", return_value="mingw"): + with self.assertRaisesRegex(RuntimeError, "64-bit"): + native_library._prepare_windows_library(Path("root")) # pylint: disable=protected-access + + +class TestSimulatorHooks(unittest.TestCase): + """ + nvc_run_flags / ghdl_elab_flags / ghdl_run_env / modelsim_vsim_flags + """ + + def setUp(self): + self.project = _BridgeKey() + self.addCleanup(bridge_setup._BRIDGES.pop, self.project, None) # pylint: disable=protected-access + + def _register(self, library_file): + bridge = bridge_setup.PythonBridge(library_file=library_file, vhdl_files=[]) + bridge_setup._BRIDGES[self.project] = bridge # pylint: disable=protected-access + return bridge + + def test_nvc_run_flags_empty_without_bridge(self): + self.assertEqual(simulator_hooks.nvc_run_flags(self.project), []) + + def test_nvc_run_flags_with_bridge(self): + bridge = self._register(Path("/some/dir/libvunit_python_bridge.so")) + self.assertEqual(simulator_hooks.nvc_run_flags(self.project), [f"--load={bridge.library_file!s}"]) + + def test_ghdl_elab_flags_empty_without_bridge(self): + self.assertEqual(simulator_hooks.ghdl_elab_flags(self.project, "llvm"), []) + + def test_ghdl_elab_flags_empty_for_mcode_and_jit(self): + bridge_dir = Path("/some/dir") + self._register(bridge_dir / "libvunit_python_bridge.so") + self.assertEqual(simulator_hooks.ghdl_elab_flags(self.project, "mcode"), []) + self.assertEqual(simulator_hooks.ghdl_elab_flags(self.project, "llvm-jit"), []) + + def test_ghdl_elab_flags_for_linking_backends(self): + bridge_dir = Path("/some/dir") + self._register(bridge_dir / "libvunit_python_bridge.so") + for backend in ("llvm", "gcc"): + self.assertEqual( + simulator_hooks.ghdl_elab_flags(self.project, backend), + [f"-Wl,-L{bridge_dir!s}"], + ) + + def test_ghdl_run_env_unchanged_without_bridge(self): + env = {"FOO": "bar"} + result = simulator_hooks.ghdl_run_env(self.project, env) + self.assertEqual(result, env) + self.assertIs(result, env) + + def test_ghdl_run_env_prepends_ld_library_path_without_mutating_input(self): + bridge_dir = Path("/some/dir") + self._register(bridge_dir / "libvunit_python_bridge.so") + env = {"LD_LIBRARY_PATH": "/existing/path"} + with mock.patch("vunit.python_bridge.native_library.sys.platform", "linux"): + result = simulator_hooks.ghdl_run_env(self.project, env) + self.assertEqual( + result["LD_LIBRARY_PATH"], + str(bridge_dir) + os.pathsep + "/existing/path", + ) + # Input must not be mutated. + self.assertEqual(env, {"LD_LIBRARY_PATH": "/existing/path"}) + self.assertIsNot(result, env) + + def test_ghdl_run_env_uses_dyld_library_path_on_macos(self): + bridge_dir = Path("/some/dir") + self._register(bridge_dir / "libvunit_python_bridge.so") + with mock.patch("vunit.python_bridge.simulator_hooks.sys.platform", "darwin"): + result = simulator_hooks.ghdl_run_env(self.project, {}) + self.assertEqual(result["DYLD_LIBRARY_PATH"], str(bridge_dir)) + + def test_ghdl_run_env_uses_path_variable_on_windows(self): + bridge_dir = Path("/some/dir") + self._register(bridge_dir / "libvunit_python_bridge.so") + with mock.patch("vunit.python_bridge.native_library.sys.platform", "win32"): + result = simulator_hooks.ghdl_run_env(self.project, {}) + self.assertEqual(result["PATH"], str(bridge_dir)) + self.assertNotIn("LD_LIBRARY_PATH", result) + + def test_modelsim_vsim_flags_empty_without_bridge(self): + with mock.patch("vunit.python_bridge.simulator_hooks.sys.platform", "linux"): + self.assertEqual(simulator_hooks.modelsim_vsim_flags(self.project), []) + + def test_modelsim_vsim_flags_disable_the_bundled_cxx_runtime_on_linux(self): + self._register(Path("/some/dir/libvunit_python_bridge_fli.so")) + with mock.patch("vunit.python_bridge.simulator_hooks.sys.platform", "linux"): + self.assertEqual(simulator_hooks.modelsim_vsim_flags(self.project), ["-noautoldlibpath"]) + + def test_modelsim_vsim_flags_empty_on_windows(self): + self._register(Path("/some/dir/vunit_python_bridge_fli.dll")) + with mock.patch("vunit.python_bridge.simulator_hooks.sys.platform", "win32"): + self.assertEqual(simulator_hooks.modelsim_vsim_flags(self.project), []) + + +class TestSimulatorIntegration(unittest.TestCase): + """ + Confirm the hooks are wired into the real NVC/GHDL command builders at the + right place. + + Entity() writes a stub source file relative to the cwd, so run these from + a scratch directory (matching the convention in test_ghdl_interface.py). + """ + + def setUp(self): + self.tempdir_cm = create_tempdir() + self.scratch_dir = self.tempdir_cm.__enter__() + self.addCleanup(self.tempdir_cm.__exit__, None, None, None) + self.cwd = os.getcwd() + os.chdir(self.scratch_dir) + self.addCleanup(os.chdir, self.cwd) + + def test_ghdl_get_command_only_adds_wl_L_for_linking_backends(self): + from tests.unit.test_test_bench import Entity # pylint: disable=import-outside-toplevel + from vunit.sim_if.ghdl import GHDLInterface # pylint: disable=import-outside-toplevel + from vunit.project import Project # pylint: disable=import-outside-toplevel + from vunit.configuration import Configuration # pylint: disable=import-outside-toplevel + from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel + + design_unit = Entity("tb_entity", file_name=str(Path("tempdir") / "file.vhd")) + design_unit.original_file_name = str(Path("tempdir") / "other_path" / "original_file.vhd") + design_unit.generic_names = ["runner_cfg", "tb_path"] + config = Configuration("name", design_unit) + + for backend, expect_flag in (("llvm", True), ("gcc", True), ("mcode", False), ("llvm-jit", False)): + with mock.patch.object(GHDLInterface, "determine_version", return_value=5.0): + simif = GHDLInterface(prefix="prefix", output_path="", backend=backend) + simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access + simif._project = Project() # pylint: disable=protected-access + simif._project.add_library("lib", "lib_path") # pylint: disable=protected-access + + bridge = bridge_setup.PythonBridge( + library_file=Path("/bridge/dir/libvunit_python_bridge.so"), vhdl_files=[] + ) + bridge_setup._BRIDGES[simif._project] = bridge # pylint: disable=protected-access + try: + cmd = simif._get_command( # pylint: disable=protected-access + config, str(Path("output_path") / "ghdl"), True, False, "tb_entity", None + ) + finally: + bridge_setup._BRIDGES.pop(simif._project, None) # pylint: disable=protected-access + + flag = f"-Wl,-L{Path('/bridge/dir')!s}" + if expect_flag: + self.assertIn(flag, cmd, f"backend={backend}") + else: + self.assertNotIn(flag, cmd, f"backend={backend}") + + def test_nvc_simulate_loads_bridge_after_dash_r(self): + from vunit.sim_if.nvc import NVCInterface # pylint: disable=import-outside-toplevel + from tests.unit.test_test_bench import Entity # pylint: disable=import-outside-toplevel + from vunit.project import Project # pylint: disable=import-outside-toplevel + from vunit.configuration import Configuration # pylint: disable=import-outside-toplevel + from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel + + design_unit = Entity("tb_entity", file_name=str(Path("tempdir") / "file.vhd")) + design_unit.original_file_name = str(Path("tempdir") / "other_path" / "original_file.vhd") + design_unit.generic_names = ["runner_cfg", "tb_path"] + config = Configuration("name", design_unit) + + with create_tempdir() as tempdir: + with mock.patch.object(NVCInterface, "determine_version", return_value=(1, 99)): + simif = NVCInterface(output_path=str(tempdir), prefix="prefix", num_threads=1) + simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access + simif._project = Project() # pylint: disable=protected-access + simif._project.add_library("lib", str(tempdir)) # pylint: disable=protected-access + + bridge = bridge_setup.PythonBridge( + library_file=Path("/bridge/dir/libvunit_python_bridge.so"), vhdl_files=[] + ) + bridge_setup._BRIDGES[simif._project] = bridge # pylint: disable=protected-access + + captured = {} + + class _FakeProcess: + def __init__(self, cmd, env=None): + captured["cmd"] = cmd + + def consume_output(self): + pass + + try: + with mock.patch("vunit.sim_if.nvc.Process", _FakeProcess): + simif.simulate(str(tempdir), "tb_entity", config, elaborate_only=False) + finally: + bridge_setup._BRIDGES.pop(simif._project, None) # pylint: disable=protected-access + + cmd = captured["cmd"] + self.assertIn("-r", cmd) + load = f"--load={Path('/bridge/dir/libvunit_python_bridge.so')!s}" + self.assertIn(load, cmd) + self.assertGreater(cmd.index(load), cmd.index("-r")) + + def test_nvc_simulate_has_no_load_flag_without_bridge(self): + from vunit.sim_if.nvc import NVCInterface # pylint: disable=import-outside-toplevel + from tests.unit.test_test_bench import Entity # pylint: disable=import-outside-toplevel + from vunit.project import Project # pylint: disable=import-outside-toplevel + from vunit.configuration import Configuration # pylint: disable=import-outside-toplevel + from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel + + design_unit = Entity("tb_entity", file_name=str(Path("tempdir") / "file.vhd")) + design_unit.original_file_name = str(Path("tempdir") / "other_path" / "original_file.vhd") + design_unit.generic_names = ["runner_cfg", "tb_path"] + config = Configuration("name", design_unit) + + with create_tempdir() as tempdir: + with mock.patch.object(NVCInterface, "determine_version", return_value=(1, 99)): + simif = NVCInterface(output_path=str(tempdir), prefix="prefix", num_threads=1) + simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access + simif._project = Project() # pylint: disable=protected-access + simif._project.add_library("lib", str(tempdir)) # pylint: disable=protected-access + + captured = {} + + class _FakeProcess: + def __init__(self, cmd, env=None): + captured["cmd"] = cmd + + def consume_output(self): + pass + + with mock.patch("vunit.sim_if.nvc.Process", _FakeProcess): + simif.simulate(str(tempdir), "tb_entity", config, elaborate_only=False) + + self.assertFalse(any(flag.startswith("--load=") for flag in captured["cmd"])) + + +class TestForeignApplicationBuild(unittest.TestCase): + """ + The VHPI application is built under the output path once and rebuilt when its inputs change. + """ + + @staticmethod + def _simulator(prefix): + simulator = mock.Mock() + simulator.name = "rivierapro" + simulator.find_prefix.return_value = str(prefix) + return simulator + + def test_builds_once_and_rebuilds_when_the_fingerprint_changes(self): + with create_tempdir() as tempdir: + output_path = Path(tempdir) / "out" + simulator = self._simulator(Path(tempdir) / "rivierapro" / "bin") + target = output_path / "rivierapro" / "libraries" / "python.dll" + + def fake_build(target, sources, simulator_prefix): # pylint: disable=unused-argument + target.write_text("built", encoding="utf-8") + + with mock.patch.object(foreign_application, "_build_vhpi", side_effect=fake_build) as build: + foreign_application.setup_vhpi_application(output_path, simulator) + foreign_application.setup_vhpi_application(output_path, simulator) + self.assertEqual(build.call_count, 1) + self.assertTrue(target.exists()) + self.assertTrue(target.with_suffix(".dll.fingerprint").exists()) + + # Another simulator installation changes the fingerprint + other = self._simulator(Path(tempdir) / "other" / "bin") + with mock.patch.object(foreign_application, "_build_vhpi", side_effect=fake_build) as build: + foreign_application.setup_vhpi_application(output_path, other) + self.assertEqual(build.call_count, 1) + + # A missing library is rebuilt even with a matching fingerprint + target.unlink() + with mock.patch.object(foreign_application, "_build_vhpi", side_effect=fake_build) as build: + foreign_application.setup_vhpi_application(output_path, other) + self.assertEqual(build.call_count, 1) + + def test_failed_build_leaves_no_fingerprint(self): + with create_tempdir() as tempdir: + output_path = Path(tempdir) / "out" + simulator = self._simulator(Path(tempdir) / "rivierapro" / "bin") + with mock.patch.object(foreign_application, "_build_vhpi", side_effect=RuntimeError("boom")): + with self.assertRaises(RuntimeError): + foreign_application.setup_vhpi_application(output_path, simulator) + self.assertFalse(list((output_path / "rivierapro" / "libraries").glob("*.fingerprint"))) + + +if __name__ == "__main__": + unittest.main() + diff --git a/vunit/builtins.py b/vunit/builtins.py index de2f4e451..e8f58faf2 100755 --- a/vunit/builtins.py +++ b/vunit/builtins.py @@ -16,6 +16,7 @@ import importlib.util import re import operator +import sys from dataclasses import dataclass from typing import TypeVar, Any, Tuple @@ -28,6 +29,7 @@ from vunit.vhdl_standard import VHDL, VHDLStandard from vunit.ui.common import get_checked_file_names_from_globs from vunit.about import version, VUnitVersion +from vunit.python_bridge.foreign_application import setup_vhpi_application LOGGER = logging.getLogger(__name__) @@ -56,6 +58,7 @@ def __init__(self, vunit_obj, vhdl_standard: VHDLStandard, simulator_class): self._vhdl_standard = vhdl_standard self._simulator_class = simulator_class self._builtins_adder = BuiltinsAdder() + self._vhdl_builtins_added = False def add(name, deps=tuple()): self._builtins_adder.add_type(name, getattr(self, f"_add_{name!s}"), deps) @@ -65,6 +68,7 @@ def add(name, deps=tuple()): add("verification_components", ["com", "osvvm"]) add("osvvm") add("random", ["osvvm"]) + add("python") def add(self, name, args=None): self._builtins_adder.add(name, args) @@ -485,6 +489,75 @@ def _add_vhdl_logging(self, use_external_log): self._vunit_lib.add_source_file(file_name) + def _add_python(self): + """ + Add the Python package (python_pkg and python_context) and the foreign language interface + implementing it for the selected simulator. + """ + if not self._vhdl_builtins_added: + raise RuntimeError("add_python() requires add_vhdl_builtins() to be called first") + + if not self._vhdl_standard >= VHDL.STD_2008: + raise RuntimeError("Python package only supports vhdl 2008 and later") + + python_package_supported_flis = {"VHPI", "FLI", "VHPIDIRECT_NVC", "VHPIDIRECT_GHDL"} + if self._simulator_class is None: + raise RuntimeError( + "Python package requires a simulator supporting one of " + f"{', '.join(sorted(python_package_supported_flis))} but no simulator was found" + ) + simulator_supported_flis = self._simulator_class.supported_foreign_language_interfaces() + if not python_package_supported_flis & simulator_supported_flis: + raise RuntimeError( + f"Python package requires support for one of {', '.join(sorted(python_package_supported_flis))} " + f"but {self._simulator_class.name} supports none of them" + ) + + src_path = VHDL_PATH / "python" / "src" + self._vunit_lib.add_source_file(src_path / "python_context.vhd") + self._vunit_lib.add_source_file(src_path / "python_pkg.vhd") + if "VHPI" in simulator_supported_flis: + # Riviera-PRO/Active-HDL, the simulators served by the VHPI application + self._vunit_lib.add_source_file(src_path / "python_pkg_vhpi.vhd") + self._setup_python_application(setup_vhpi_application) + else: + self._add_python_bridge() + + def _setup_python_application(self, setup): + """ + Build the VHPI application of the Python package under the output path when needed. + """ + try: + setup( + self._vunit_obj._output_path, # pylint: disable=protected-access + self._simulator_class, + ) + except RuntimeError as exc: + LOGGER.error("%s", exc) + sys.exit(1) + + def _add_python_bridge(self): + """ + Add the VUnit Python bridge, the implementation of the Python package for NVC and GHDL + (VHPIDIRECT) and Questa/ModelSim (FLI), building its native library when needed. + """ + # pylint: disable=import-outside-toplevel + from vunit.python_bridge.bridge import setup + from vunit.python_bridge.native_library import PythonBridgeError + + try: + bridge = setup( + self._vunit_obj._project, # pylint: disable=protected-access + self._vunit_obj._output_path, # pylint: disable=protected-access + self._simulator_class, + self._vunit_obj._run_script_path, # pylint: disable=protected-access + ) + except PythonBridgeError as exc: + LOGGER.error("%s", exc) + sys.exit(1) + for file_name in bridge.vhdl_files: + self._vunit_lib.add_source_file(file_name) + def add_verilog_builtins(self): """ Add Verilog builtins @@ -518,6 +591,7 @@ def add_vhdl_builtins(self, external=None, use_external_log=None): "path", ): self._add_files(VHDL_PATH / path / "src" / "*.vhd") + self._vhdl_builtins_added = True def osvvm_is_installed(): diff --git a/vunit/python_bridge/__init__.py b/vunit/python_bridge/__init__.py new file mode 100644 index 000000000..62b2a3042 --- /dev/null +++ b/vunit/python_bridge/__init__.py @@ -0,0 +1,23 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + + +""" +VHDL-to-Python bridge enabled by ``add_python()``. + +The bridge is a small C library (native/*.c) that embeds CPython in the +simulator process and is called from VHDL through VHPIDIRECT foreign +subprograms. It is the NVC/GHDL implementation of ``python_ffi_pkg``, the +private engine below the public ``python_pkg`` API. Modules: + +* bridge: setup of a project (called by add_python). +* native_library: compiles and caches the library on Linux, selects the + prebuilt DLL on Windows. +* simulator_hooks: makes NVC and GHDL find the library. +* runtime: runs inside the simulator's embedded interpreter. + +Nothing in this package runs unless Python support is explicitly requested. +""" diff --git a/vunit/python_bridge/bridge.py b/vunit/python_bridge/bridge.py new file mode 100644 index 000000000..7fb516d3e --- /dev/null +++ b/vunit/python_bridge/bridge.py @@ -0,0 +1,185 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + + +""" +Setup of the Python bridge of a project: native library, configuration and generated VHDL. +""" + +import os +import re +import sys +from pathlib import Path +from typing import Callable, List, Optional +from weakref import WeakKeyDictionary + +from .native_library import ( + PACKAGE_PATH, + PythonBridgeError, + check_python_build, + prepare_library, + windows_python_dll, +) + +RUNTIME_SOURCE = PACKAGE_PATH / "runtime.py" +VHDL_SOURCE_PATH = PACKAGE_PATH.parent / "vhdl" / "python" / "src" +BRIDGE_PACKAGE_TEMPLATE = VHDL_SOURCE_PATH / "python_bridge_pkg.vhd.in" +CONFIG_FILE_NAME = "vunit_python_bridge.cfg" + +# Simulators reaching the bridge through VHPIDIRECT and through the FLI +FLI_SIMULATORS = ("modelsim",) +SUPPORTED_SIMULATORS = ("nvc", "ghdl") + FLI_SIMULATORS + +# GHDL backends that link the design ahead of time. For these the library +# token in the VHPIDIRECT attribute is passed to the linker instead of being +# dlopen()ed at run time. +GHDL_LINKING_BACKENDS = ("llvm", "gcc") + +# {foreign:} placeholder of the bridge package template +FOREIGN_PATTERN = re.compile(r"\{foreign:(\w+)\}") + +_BRIDGES: "WeakKeyDictionary[object, PythonBridge]" = WeakKeyDictionary() + + +class PythonBridge: + """ + A prepared bridge: native library, its configuration and the generated VHDL. + """ + + def __init__(self, library_file: Path, vhdl_files: List[Path]) -> None: + self.library_file = library_file + self.vhdl_files = vhdl_files + + @property + def directory(self) -> Path: + return self.library_file.parent + + +def setup(project, output_path: str, simulator_class, run_script_path: Path) -> PythonBridge: + """ + Prepare the Python bridge for a project. Called by add_python(). + + :param run_script_path: The run script. Its directory is the base of + relative Python file names, like for import_run_script. + :returns: The bridge. Its vhdl_files are to be added to vunit_lib. + """ + simulator_name = None if simulator_class is None else simulator_class.name + if simulator_name is not None and simulator_name not in SUPPORTED_SIMULATORS: + raise PythonBridgeError( + f"VHDL Python support (add_python()) requires NVC, GHDL or Questa/ModelSim, " + f"it is not supported for {simulator_name}" + ) + + check_python_build() + + is_fli = simulator_name in FLI_SIMULATORS + root = Path(output_path) / "python_bridge" + library_file = prepare_library(root, Path(simulator_class.find_prefix()) if is_fli else None) + base_dir = str(Path(run_script_path).resolve().parent) + _write_if_changed(library_file.parent / CONFIG_FILE_NAME, _config_text(base_dir)) + + bridge_package = root / "vhdl" / "python_bridge_pkg.vhd" + _write_if_changed( + bridge_package, + _render_bridge_package( + _fli_foreign(library_file) + if is_fli + else _vhpidirect_foreign(_vhpidirect_token(simulator_name, simulator_class, library_file)) + ), + ) + + bridge = PythonBridge( + library_file, + [ + bridge_package, + VHDL_SOURCE_PATH / "python_ffi_pkg_bridge.vhd", + ], + ) + _BRIDGES[project] = bridge + return bridge + + +def _vhpidirect_token(simulator_name, simulator_class, library_file: Path) -> str: + """ + The library token of the VHPIDIRECT attributes: the file name the simulator dlopen()s, + or the linker flag of the GHDL backends that link the design ahead of time. + """ + if simulator_name == "ghdl" and _ghdl_backend(simulator_class) in GHDL_LINKING_BACKENDS: + return "-lvunit_python_bridge" + return library_file.name + + +def _vhpidirect_foreign(library_token: str) -> Callable[[str], str]: + """ + The VHPIDIRECT attribute string of an entry point, for NVC and GHDL. + """ + return lambda entry_point: f"VHPIDIRECT {library_token} {entry_point}" + + +def _fli_foreign(library_file: Path) -> Callable[[str], str]: + """ + The FLI attribute string of an entry point: the name of its wrapper in native/fli.c and the + library to load it from. Questa accepts the absolute path, so the library can stay in the + bridge cache directory instead of being copied next to the simulation. + """ + return lambda entry_point: f"fli_{entry_point} {library_file!s}" + + +def _render_bridge_package(foreign: Callable[[str], str]) -> str: + """ + The generated python_bridge_pkg.vhd: the template with every {foreign:} + placeholder replaced by the attribute string of the selected simulator. + """ + template = BRIDGE_PACKAGE_TEMPLATE.read_text(encoding="utf-8") + return FOREIGN_PATTERN.sub(lambda match: foreign(match.group(1)), template) + + +def get_bridge(project) -> Optional[PythonBridge]: + """ + The bridge of a project, None unless Python support is enabled. + """ + return _BRIDGES.get(project) + + +def _config_text(base_dir: str) -> str: + """ + Content of the configuration file read by the bridge library at run time. + """ + lines = { + "executable": sys.executable, + "prefix": sys.prefix, + "runtime": str(RUNTIME_SOURCE), + "base_dir": base_dir, + } + if sys.platform == "win32": + lines["python_dll"] = windows_python_dll() + for key, value in lines.items(): + if "\n" in value or "\r" in value: + raise PythonBridgeError(f"VHDL Python support cannot handle line breaks in the path {value!r}") + return "".join(f"{key}={value}\n" for key, value in lines.items()) + + +def _write_if_changed(path: Path, text: str) -> None: + """ + Write a file unless it already has the given content, keeping timestamps stable. + """ + data = text.encode("utf-8") + if path.is_file() and path.read_bytes() == data: + return + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + f".{os.getpid()}.tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def _ghdl_backend(simulator_class) -> Optional[str]: + """ + Backend of the GHDL that will be used, None if not found. + """ + prefix = simulator_class.find_prefix() + if prefix is None: + return None + return simulator_class.determine_backend(prefix) diff --git a/vunit/python_bridge/foreign_application.py b/vunit/python_bridge/foreign_application.py new file mode 100644 index 000000000..6fb569521 --- /dev/null +++ b/vunit/python_bridge/foreign_application.py @@ -0,0 +1,86 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Building the foreign language application implementing ``python_pkg``/``python_context`` +(see :ref:`python_bridge`) for the simulators the VUnit Python bridge does not serve. + +``setup_vhpi_application`` is called by :meth:`add_python() ` for +Riviera-PRO/Active-HDL (VHPI). The application is built under the output path on first use and +rebuilt when its sources, the Python running VUnit or the simulator change. +""" + +from pathlib import Path +import sys +import subprocess +import hashlib + + +SRC_PATH = Path(__file__).parent.parent.resolve() / "vhdl" / "python" / "src" + + +def setup_vhpi_application(output_path, simulator_class): + """ + Build the VHPI application for Riviera-PRO/Active-HDL under the output path, unless the one + already there was built from the same sources for the same Python and simulator. + """ + target = Path(output_path) / simulator_class.name / "libraries" / "python.dll" + _build_if_stale( + target, [SRC_PATH / "python_pkg_vhpi.c", SRC_PATH / "python_pkg.c"], simulator_class, _build_vhpi + ) + + +def _fingerprint(sources, simulator_prefix): + """ + Everything the built application depends on: the C sources and headers, the Python that is + embedded and the simulator whose headers and libraries are used. + """ + digest = hashlib.sha256() + for path in sorted(sources) + sorted(SRC_PATH.glob("*.h")): + digest.update(path.name.encode("utf-8") + b"\0" + path.read_bytes() + b"\0") + for item in [sys.executable, sys.prefix, sys.version, sys.platform, str(simulator_prefix)]: + digest.update(item.encode("utf-8") + b"\0") + return digest.hexdigest() + + +def _build_if_stale(target, sources, simulator_class, build): + """ + Build target with build(target, sources, simulator_prefix) unless it is up to date. + """ + simulator_prefix = Path(simulator_class.find_prefix()).resolve() + fingerprint = _fingerprint(sources, simulator_prefix) + fingerprint_file = target.with_suffix(target.suffix + ".fingerprint") + if target.exists() and fingerprint_file.exists() and fingerprint_file.read_text(encoding="utf-8") == fingerprint: + return + + target.parent.mkdir(parents=True, exist_ok=True) + if fingerprint_file.exists(): + fingerprint_file.unlink() + build(target, sources, simulator_prefix) + fingerprint_file.write_text(fingerprint, encoding="utf-8") + + +def _run(args, what): + """ + Run a compiler command, failing with its output. + """ + proc = subprocess.run(args, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise RuntimeError(f"Failed to {what}:\n{' '.join(str(arg) for arg in args)}\n{proc.stdout}{proc.stderr}") + + +def _build_vhpi(target, sources, simulator_prefix): + """ + Compile the VHPI application with the ccomp compiler driver of Riviera-PRO/Active-HDL. + """ + python_include = Path(sys.executable).parent.resolve() / "include" + python_libs = Path(sys.executable).parent.resolve() / "libs" + ccomp = simulator_prefix / ("ccomp.exe" if sys.platform == "win32" else "ccomp") + args = [str(ccomp), "-vhpi", "-dbg", "-verbose", "-o", f'"{target}"'] + args += ["-l", f"python{sys.version_info[0]}{sys.version_info[1]}", "-l", "python3", "-l", "_tkinter"] + args += ["-I", f'"{python_include}"', "-I", f'"{SRC_PATH}"', "-L", f'"{python_libs}"'] + args += [" ".join(f'"{path}"' for path in sources)] + _run(args, "compile the VHPI application") diff --git a/vunit/python_bridge/native/arguments.c b/vunit/python_bridge/native/arguments.c new file mode 100644 index 000000000..b2a288cf8 --- /dev/null +++ b/vunit/python_bridge/native/arguments.c @@ -0,0 +1,191 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * Data transferred from VHDL: a byte buffer for strings (Python source code, + * expressions, file and session names) and the integer_array_t value being + * pushed, which is staged in the runtime and then referred to from the Python + * source text VHDL builds. + */ + +#include "bridge.h" + +#include +#include + +/* Raw byte buffer used to transfer strings from VHDL, owned. */ +static char *g_buffer = NULL; +static size_t g_buffer_length = 0; +static size_t g_buffer_capacity = 0; + +/* Strong references, only touched with the GIL held. */ +static PyObject *g_array = NULL; /* NumPy array of the integer_array_t being pushed */ +static PyObject *g_pending = NULL; /* bytearray backing it */ + +/* View into g_pending. It is owned by us and never resized while referenced + * (NumPy holds a buffer export on it), so the pointer stays valid. */ +static char *g_pending_data = NULL; +static size_t g_pending_size = 0; +static size_t g_pending_position = 0; + +/* New reference to the transfer buffer as a str. */ +PyObject *vpy_buffer_as_str(void) { + return PyUnicode_DecodeUTF8(g_buffer == NULL ? "" : g_buffer, (Py_ssize_t)g_buffer_length, "surrogateescape"); +} + +void vpy_clear_arguments(void) { + Py_CLEAR(g_array); + Py_CLEAR(g_pending); + g_pending_data = NULL; + g_pending_size = 0; + g_pending_position = 0; +} + +/* ------------------------------------------------------------------------ */ +/* Entry points */ +/* ------------------------------------------------------------------------ */ + +VPY_EXPORT int32_t vpy_buffer_clear(void) { + g_buffer_length = 0; + return VPY_OK; +} + +VPY_EXPORT int32_t vpy_buffer_append(const char *chunk, int32_t length) { + size_t required; + + if (length <= 0) { + return VPY_OK; + } + required = g_buffer_length + (size_t)length; + if (required > g_buffer_capacity) { + size_t capacity = g_buffer_capacity == 0 ? 4096 : g_buffer_capacity; + char *buffer; + + while (capacity < required) { + capacity *= 2; + } + buffer = (char *)realloc(g_buffer, capacity); + if (buffer == NULL) { + vpy_set_error("Out of memory while transferring a string to Python"); + return VPY_ERROR; + } + g_buffer = buffer; + g_buffer_capacity = capacity; + } + memcpy(g_buffer + g_buffer_length, chunk, (size_t)length); + g_buffer_length = required; + return VPY_OK; +} + +/* + * Push an integer_array_t value. Allocates the Python-owned storage that the + * element values are subsequently written to with vpy_array_write. The value + * is made available to Python source code by vpy_stage. + */ +VPY_EXPORT int32_t vpy_push_array(int32_t length, int32_t width, int32_t height, int32_t depth, int32_t bit_width, + int32_t is_signed) { + PyGILState_STATE gil; + size_t size = length > 0 ? (size_t)length * sizeof(int32_t) : 0; + PyObject *storage = NULL; + int status = VPY_ERROR; + + if (vpy_initialize() != VPY_OK || vpy_enter(&gil) != VPY_OK) { + return VPY_ERROR; + } + vpy_clear_arguments(); + if (length < 0) { + vpy_set_error("Internal error: negative array length"); + goto done; + } + + storage = PyByteArray_FromStringAndSize(NULL, (Py_ssize_t)size); + if (storage == NULL) { + vpy_set_error_from_python(); + goto done; + } + if (size > 0) { + memset(PyByteArray_AsString(storage), 0, size); + } + + g_array = PyObject_CallMethod(vpy_runtime(), "make_array", "Oiiiiii", storage, (int)length, (int)width, (int)height, + (int)depth, (int)bit_width, (int)(is_signed != 0)); + if (g_array == NULL) { + vpy_set_error_from_python(); + goto done; + } + + /* The array keeps the storage alive; we keep our own reference too so the + * cached data pointer is valid until the next vpy_clear_arguments(). */ + g_pending = storage; + storage = NULL; + g_pending_data = PyByteArray_AsString(g_pending); + g_pending_size = size; + g_pending_position = 0; + status = VPY_OK; + +done: + Py_XDECREF(storage); + PyGILState_Release(gil); + return status; +} + +VPY_EXPORT int32_t vpy_array_write(const int32_t *chunk, int32_t length) { + size_t bytes; + + if (length <= 0) { + return VPY_OK; + } + bytes = (size_t)length * sizeof(int32_t); + if (g_pending_data == NULL || g_pending_position + bytes > g_pending_size) { + vpy_set_error("Internal error: array data written out of bounds"); + return VPY_ERROR; + } + memcpy(g_pending_data + g_pending_position, chunk, bytes); + g_pending_position += bytes; + return VPY_OK; +} + +/* + * Stage the pushed array in the runtime. Python source code built by VHDL + * refers to it as __vunit__.staged(). + * + * Returns the id (>= 1) or -1 on failure, with the error text set. + */ +VPY_EXPORT int32_t vpy_stage(void) { + PyGILState_STATE gil; + PyObject *id; + long value; + int32_t result = -1; + + if (vpy_initialize() != VPY_OK || vpy_enter(&gil) != VPY_OK) { + return -1; + } + if (g_array == NULL) { + vpy_set_error("Internal error: no value pushed before vpy_stage"); + goto done; + } + id = PyObject_CallMethod(vpy_runtime(), "stage_value", "O", g_array); + if (id == NULL) { + vpy_set_error_from_python(); + goto done; + } + value = PyLong_AsLong(id); + Py_DECREF(id); + if (PyErr_Occurred()) { + vpy_set_error_from_python(); + goto done; + } + if (value < 1 || value > INT32_MAX) { + vpy_set_error("Internal error: invalid staged value id"); + goto done; + } + result = (int32_t)value; + +done: + vpy_clear_arguments(); + PyGILState_Release(gil); + return result; +} diff --git a/vunit/python_bridge/native/bridge.h b/vunit/python_bridge/native/bridge.h new file mode 100644 index 000000000..a4dac7188 --- /dev/null +++ b/vunit/python_bridge/native/bridge.h @@ -0,0 +1,117 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * Internal header of the native bridge between VHDL and an embedded CPython + * interpreter. The bridge is a private implementation detail of + * python_ffi_pkg: VHDL builds Python source text and the bridge executes or + * evaluates it. VHDL reaches the entry points below through VHPIDIRECT on NVC + * and GHDL and through the FLI front end in fli.c on Questa/ModelSim. + * + * Modules: + * error.c error text reported to VHDL + * config.c configuration file written by VUnit next to the library + * interpreter.c starting the interpreter and loading ../runtime.py + * arguments.c strings and integer_array_t values transferred from VHDL + * operations.c setup, exec, eval and results transferred back to VHDL + * fli.c Questa/ModelSim front end, only built for that simulator + * + * ABI rules, chosen to be identical for NVC and GHDL on all platforms, and + * translated to the FLI parameter passing conventions by fli.c: + * - VHDL integer <-> int32_t, VHDL real <-> double. + * - Booleans are passed as integers (0/1), never as VHDL boolean. + * - Strings and integer vectors only cross the boundary as *constrained* + * chunks (plain pointers) with explicit offsets/lengths. Unconstrained + * arrays (fat pointers) are simulator specific and are never used. + * - Every operation that can fail returns a status (VPY_OK or VPY_ERROR). + * The error text is retrieved with vpy_error_length/vpy_error_read. + * + * Python objects are only touched with the GIL held. Python data symbols + * (e.g. Py_None) must not be used: the Windows DLL delay-loads the Python + * DLL, which only supports functions. + */ + +#ifndef VUNIT_PYTHON_BRIDGE_H +#define VUNIT_PYTHON_BRIDGE_H + +#define PY_SSIZE_T_CLEAN +#include + +#include +#include + +#ifdef Py_GIL_DISABLED +#error "VHDL Python support does not work with free-threaded CPython builds" +#endif + +#ifdef _WIN32 +#define VPY_EXPORT __declspec(dllexport) +#else +#define VPY_EXPORT __attribute__((visibility("default"))) +#endif + +#define VPY_OK 0 +#define VPY_ERROR 1 + +/* + * Entry points called from VHDL (declared in python_bridge_pkg.vhd.in, + * directly for VHPIDIRECT and through the fli_ prefixed wrappers of fli.c for + * the FLI). Strings cross the boundary through a buffer; integer_array_t + * values are pushed and staged, everything else is Python source text built + * by VHDL. + */ +VPY_EXPORT int32_t vpy_setup(void); +VPY_EXPORT int32_t vpy_cleanup(void); +VPY_EXPORT int32_t vpy_buffer_clear(void); +VPY_EXPORT int32_t vpy_buffer_append(const char *chunk, int32_t length); +VPY_EXPORT int32_t vpy_begin(void); +VPY_EXPORT int32_t vpy_execute(int32_t is_file); +VPY_EXPORT int32_t vpy_eval(int32_t kind, int32_t width); +VPY_EXPORT int32_t vpy_push_array(int32_t length, int32_t width, int32_t height, int32_t depth, int32_t bit_width, + int32_t is_signed); +VPY_EXPORT int32_t vpy_array_write(const int32_t *chunk, int32_t length); +VPY_EXPORT int32_t vpy_stage(void); +VPY_EXPORT int32_t vpy_result_integer(void); +VPY_EXPORT double vpy_result_real(void); +VPY_EXPORT int32_t vpy_result_meta(int32_t index); +VPY_EXPORT void vpy_result_read_string(char *chunk, int32_t offset, int32_t length); +VPY_EXPORT void vpy_result_read_integers(int32_t *chunk, int32_t offset, int32_t length); +VPY_EXPORT void vpy_result_read_reals(double *chunk, int32_t offset, int32_t length); +VPY_EXPORT int32_t vpy_error_length(void); +VPY_EXPORT void vpy_error_read(char *chunk, int32_t offset, int32_t length); + +/* Internal functions shared between the modules */ + +/* error.c */ +void vpy_set_error(const char *text); +void vpy_set_error_bytes(const char *text, size_t length); +void vpy_set_error2(const char *prefix, const char *detail); +void vpy_set_error_from_python(void); /* GIL held */ +int vpy_has_error(void); + +/* config.c */ +typedef struct { + char *executable; /* sys.executable of the Python running VUnit */ + char *prefix; /* its sys.prefix, verified by the runtime */ + char *runtime; /* path of runtime.py */ + char *base_dir; /* base of relative Python file names */ + char *python_dll; /* Windows only: path of the Python DLL */ +} vpy_config_t; + +int vpy_read_config(vpy_config_t *config); + +/* interpreter.c */ +int vpy_initialize(void); +int vpy_is_initialized(void); +PyObject *vpy_runtime(void); /* borrowed, NULL before initialization */ +int vpy_enter(PyGILState_STATE *gil); +int vpy_finish_call(PyObject *ret); /* GIL held, steals ret */ + +/* arguments.c (GIL held) */ +PyObject *vpy_buffer_as_str(void); +void vpy_clear_arguments(void); + +#endif diff --git a/vunit/python_bridge/native/config.c b/vunit/python_bridge/native/config.c new file mode 100644 index 000000000..3a27045e6 --- /dev/null +++ b/vunit/python_bridge/native/config.c @@ -0,0 +1,199 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * Configuration file written by VUnit next to the bridge library. It tells + * the bridge which Python to embed, where the runtime is and the base + * directory of relative Python file names. + */ + +#include "bridge.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#define DIRECTORY_SEPARATOR '\\' +#else +#include +#define DIRECTORY_SEPARATOR '/' +#endif + +#define CONFIG_FILE_NAME "vunit_python_bridge.cfg" + +/* Return a malloc'ed UTF-8 path of the directory containing this library. */ +static char *get_library_directory(void) { + char *path; + char *separator; +#ifdef _WIN32 + static wchar_t wpath[32768]; /* static: keep it off the simulator stack */ + HMODULE module = NULL; + DWORD length; + int utf8_length; + + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCWSTR)(void *)&get_library_directory, &module)) { + return NULL; + } + length = GetModuleFileNameW(module, wpath, (DWORD)(sizeof(wpath) / sizeof(wpath[0]))); + if (length == 0 || length >= sizeof(wpath) / sizeof(wpath[0])) { + return NULL; + } + utf8_length = WideCharToMultiByte(CP_UTF8, 0, wpath, (int)length, NULL, 0, NULL, NULL); + if (utf8_length <= 0) { + return NULL; + } + path = (char *)malloc((size_t)utf8_length + 1); + if (path == NULL) { + return NULL; + } + WideCharToMultiByte(CP_UTF8, 0, wpath, (int)length, path, utf8_length, NULL, NULL); + path[utf8_length] = '\0'; + separator = strrchr(path, '\\'); + if (separator == NULL) { + separator = strrchr(path, '/'); + } +#else + Dl_info info; + + if (dladdr((void *)&get_library_directory, &info) == 0 || info.dli_fname == NULL) { + return NULL; + } + path = strdup(info.dli_fname); + if (path == NULL) { + return NULL; + } + separator = strrchr(path, '/'); +#endif + if (separator == NULL) { + free(path); + return NULL; + } + *separator = '\0'; + return path; +} + +static FILE *open_utf8_path(const char *path) { +#ifdef _WIN32 + int length = MultiByteToWideChar(CP_UTF8, 0, path, -1, NULL, 0); + wchar_t *wpath; + FILE *file; + + if (length <= 0) { + return NULL; + } + wpath = (wchar_t *)malloc(sizeof(wchar_t) * (size_t)length); + if (wpath == NULL) { + return NULL; + } + MultiByteToWideChar(CP_UTF8, 0, path, -1, wpath, length); + file = _wfopen(wpath, L"rb"); + free(wpath); + return file; +#else + return fopen(path, "rb"); +#endif +} + +/* The configuration file next to this library, opened for reading. */ +static FILE *open_config_file(void) { + char *directory = get_library_directory(); + size_t directory_length; + char *path; + FILE *file; + + if (directory == NULL) { + vpy_set_error("Failed to determine the location of the Python bridge library"); + return NULL; + } + directory_length = strlen(directory); + path = (char *)malloc(directory_length + 1 + sizeof(CONFIG_FILE_NAME)); + if (path == NULL) { + free(directory); + vpy_set_error("Out of memory"); + return NULL; + } + memcpy(path, directory, directory_length); + path[directory_length] = DIRECTORY_SEPARATOR; + memcpy(path + directory_length + 1, CONFIG_FILE_NAME, sizeof(CONFIG_FILE_NAME)); + free(directory); + + file = open_utf8_path(path); + if (file == NULL) { + vpy_set_error2("Failed to open the Python bridge configuration file ", path); + } + free(path); + return file; +} + +/* The field of config for a key, NULL for unknown keys. */ +static char **config_field(vpy_config_t *config, const char *key) { + if (strcmp(key, "executable") == 0) { + return &config->executable; + } + if (strcmp(key, "prefix") == 0) { + return &config->prefix; + } + if (strcmp(key, "runtime") == 0) { + return &config->runtime; + } + if (strcmp(key, "base_dir") == 0) { + return &config->base_dir; + } + if (strcmp(key, "python_dll") == 0) { + return &config->python_dll; + } + return NULL; +} + +/* + * Read the "key=value" lines (UTF-8) of the configuration file into config, + * whose fields must be NULL or malloc'ed. Returns VPY_ERROR with the error + * text set on failure. + */ +int vpy_read_config(vpy_config_t *config) { + FILE *file = open_config_file(); + char line[8192]; + + if (file == NULL) { + return VPY_ERROR; + } + + while (fgets(line, (int)sizeof(line), file) != NULL) { + size_t length = strlen(line); + char *separator; + char **field; + + while (length > 0 && (line[length - 1] == '\n' || line[length - 1] == '\r')) { + line[--length] = '\0'; + } + separator = strchr(line, '='); + if (separator == NULL) { + continue; + } + *separator = '\0'; + field = config_field(config, line); + if (field != NULL) { + free(*field); + *field = strdup(separator + 1); + } + } + fclose(file); + + if (config->executable == NULL || config->prefix == NULL || config->runtime == NULL || config->base_dir == NULL) { + vpy_set_error("Incomplete Python bridge configuration file"); + return VPY_ERROR; + } +#ifdef _WIN32 + if (config->python_dll == NULL) { + vpy_set_error("The Python bridge configuration file lacks the Python DLL path"); + return VPY_ERROR; + } +#endif + return VPY_OK; +} diff --git a/vunit/python_bridge/native/error.c b/vunit/python_bridge/native/error.c new file mode 100644 index 000000000..b6e96a19b --- /dev/null +++ b/vunit/python_bridge/native/error.c @@ -0,0 +1,130 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * Error text of the last failed operation, reported to VHDL. + */ + +#include "bridge.h" + +#include +#include + +/* UTF-8, owned. Sticky when initialization failed. */ +static char *g_error = NULL; +static size_t g_error_length = 0; + +void vpy_set_error_bytes(const char *text, size_t length) { + char *copy = (char *)malloc(length + 1); + + free(g_error); + g_error = NULL; + g_error_length = 0; + if (copy == NULL) { + return; + } + memcpy(copy, text, length); + copy[length] = '\0'; + g_error = copy; + g_error_length = length; +} + +void vpy_set_error(const char *text) { vpy_set_error_bytes(text, strlen(text)); } + +/* prefix may be the current error text, it is copied before being replaced. */ +void vpy_set_error2(const char *prefix, const char *detail) { + size_t prefix_length = strlen(prefix); + size_t detail_length = detail == NULL ? 0 : strlen(detail); + char *text = (char *)malloc(prefix_length + detail_length + 1); + + if (text == NULL) { + vpy_set_error(prefix); + return; + } + memcpy(text, prefix, prefix_length); + if (detail_length > 0) { + memcpy(text + prefix_length, detail, detail_length); + } + text[prefix_length + detail_length] = '\0'; + vpy_set_error_bytes(text, prefix_length + detail_length); + free(text); +} + +int vpy_has_error(void) { return g_error != NULL; } + +/* New reference to the raised exception, which is cleared. NULL if none. */ +static PyObject *take_exception(void) { +#if PY_VERSION_HEX >= 0x030C0000 + return PyErr_GetRaisedException(); +#else + PyObject *type, *value, *traceback; + + PyErr_Fetch(&type, &value, &traceback); + PyErr_NormalizeException(&type, &value, &traceback); + if (value != NULL && traceback != NULL) { + PyException_SetTraceback(value, traceback); + } + Py_XDECREF(type); + Py_XDECREF(traceback); + return value; +#endif +} + +/* + * Convert the currently raised Python exception into the error text and clear + * it. Formatting is delegated to the runtime, which hides bridge-internal + * traceback frames; plain str() and a fixed text are the fallbacks. + */ +void vpy_set_error_from_python(void) { + PyObject *exc = take_exception(); + PyObject *text = NULL; + + if (exc == NULL) { + vpy_set_error("Unknown Python error (no exception set)"); + return; + } + + if (vpy_runtime() != NULL) { + text = PyObject_CallMethod(vpy_runtime(), "format_exception", "O", exc); + if (text == NULL) { + PyErr_Clear(); + } + } + if (text == NULL) { + text = PyObject_Str(exc); + if (text == NULL) { + PyErr_Clear(); + } + } + + if (text != NULL && PyUnicode_Check(text)) { + Py_ssize_t length; + const char *utf8 = PyUnicode_AsUTF8AndSize(text, &length); + + if (utf8 != NULL) { + vpy_set_error_bytes(utf8, (size_t)length); + } else { + PyErr_Clear(); + vpy_set_error("Python error (message could not be encoded)"); + } + } else { + vpy_set_error("Python error (message could not be formatted)"); + } + + Py_XDECREF(text); + Py_DECREF(exc); +} + +VPY_EXPORT int32_t vpy_error_length(void) { + return g_error_length > INT32_MAX ? INT32_MAX : (int32_t)g_error_length; +} + +VPY_EXPORT void vpy_error_read(char *chunk, int32_t offset, int32_t length) { + if (g_error == NULL || offset < 0 || length <= 0 || (size_t)offset + (size_t)length > g_error_length) { + return; + } + memcpy(chunk, g_error + offset, (size_t)length); +} diff --git a/vunit/python_bridge/native/fli.c b/vunit/python_bridge/native/fli.c new file mode 100644 index 000000000..a25cf0fb0 --- /dev/null +++ b/vunit/python_bridge/native/fli.c @@ -0,0 +1,98 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * FLI front end of the bridge, compiled into the library only for + * Questa/ModelSim. Every entry point of bridge.h gets an fli_ prefixed + * foreign subprogram that converts the FLI parameters and delegates, so that + * the bridge itself, the generated VHDL and python_ffi_pkg_bridge.vhd are the + * same for all simulators. + * + * Parameter and return type mapping, from the tables of "Matching VHDL + * Parameters with C Parameters" and "Matching VHDL Return Types with C Return + * Types" in the Questa Foreign Language Interface Manual: + * - A VHDL integer IN parameter of class constant is passed by value as int. + * - An array parameter, IN or OUT, is passed as an mtiVariableIdT handle. + * mti_GetArrayVarValue(id, NULL) returns a pointer to the storage of the + * variable itself, which is both read and written in place. The element + * type decides what it points to: char for string (an enumeration with at + * most 256 values), mtiInt32T for integer_vector and double for + * real_vector, which is exactly the ABI of bridge.h. + * - A function returning integer returns int, one returning real returns + * the mtiRealT union, assigned with MTI_ASSIGN_TO_REAL. + * Arrays are not null terminated and the handles are only valid during the + * call, neither of which matters here: every chunk carries its length and + * nothing is kept after the call returns. + */ + +#include "bridge.h" + +#include "mti.h" + +/* ------------------------------------------------------------------------ */ +/* Scalars only: passed by value and returned as int */ +/* ------------------------------------------------------------------------ */ + +VPY_EXPORT int fli_vpy_setup(void) { return (int)vpy_setup(); } + +VPY_EXPORT int fli_vpy_cleanup(void) { return (int)vpy_cleanup(); } + +VPY_EXPORT int fli_vpy_buffer_clear(void) { return (int)vpy_buffer_clear(); } + +VPY_EXPORT int fli_vpy_begin(void) { return (int)vpy_begin(); } + +VPY_EXPORT int fli_vpy_execute(int is_file) { return (int)vpy_execute((int32_t)is_file); } + +VPY_EXPORT int fli_vpy_eval(int kind, int width) { return (int)vpy_eval((int32_t)kind, (int32_t)width); } + +VPY_EXPORT int fli_vpy_push_array(int length, int width, int height, int depth, int bit_width, int is_signed) { + return (int)vpy_push_array((int32_t)length, (int32_t)width, (int32_t)height, (int32_t)depth, (int32_t)bit_width, + (int32_t)is_signed); +} + +VPY_EXPORT int fli_vpy_stage(void) { return (int)vpy_stage(); } + +VPY_EXPORT int fli_vpy_result_integer(void) { return (int)vpy_result_integer(); } + +VPY_EXPORT int fli_vpy_result_meta(int index) { return (int)vpy_result_meta((int32_t)index); } + +VPY_EXPORT int fli_vpy_error_length(void) { return (int)vpy_error_length(); } + +/* A VHDL function returning real returns the mtiRealT union, not a double. */ +VPY_EXPORT mtiRealT fli_vpy_result_real(void) { + mtiRealT result; + + MTI_ASSIGN_TO_REAL(result, vpy_result_real()); + return result; +} + +/* ------------------------------------------------------------------------ */ +/* Chunks: constrained arrays passed as mtiVariableIdT */ +/* ------------------------------------------------------------------------ */ + +VPY_EXPORT int fli_vpy_buffer_append(mtiVariableIdT chunk, int length) { + return (int)vpy_buffer_append((const char *)mti_GetArrayVarValue(chunk, NULL), (int32_t)length); +} + +VPY_EXPORT int fli_vpy_array_write(mtiVariableIdT chunk, int length) { + return (int)vpy_array_write((const int32_t *)mti_GetArrayVarValue(chunk, NULL), (int32_t)length); +} + +VPY_EXPORT void fli_vpy_result_read_string(mtiVariableIdT chunk, int offset, int length) { + vpy_result_read_string((char *)mti_GetArrayVarValue(chunk, NULL), (int32_t)offset, (int32_t)length); +} + +VPY_EXPORT void fli_vpy_result_read_integers(mtiVariableIdT chunk, int offset, int length) { + vpy_result_read_integers((int32_t *)mti_GetArrayVarValue(chunk, NULL), (int32_t)offset, (int32_t)length); +} + +VPY_EXPORT void fli_vpy_result_read_reals(mtiVariableIdT chunk, int offset, int length) { + vpy_result_read_reals((double *)mti_GetArrayVarValue(chunk, NULL), (int32_t)offset, (int32_t)length); +} + +VPY_EXPORT void fli_vpy_error_read(mtiVariableIdT chunk, int offset, int length) { + vpy_error_read((char *)mti_GetArrayVarValue(chunk, NULL), (int32_t)offset, (int32_t)length); +} diff --git a/vunit/python_bridge/native/interpreter.c b/vunit/python_bridge/native/interpreter.c new file mode 100644 index 000000000..fcac0be2f --- /dev/null +++ b/vunit/python_bridge/native/interpreter.c @@ -0,0 +1,276 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * The embedded interpreter. It is initialized lazily on first use, in the + * Python environment that runs VUnit, and kept alive for the lifetime of the + * simulator process (it is never finalized). + */ + +#include "bridge.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +typedef enum { STATE_UNINITIALIZED, STATE_READY, STATE_FAILED } state_t; + +static state_t g_state = STATE_UNINITIALIZED; +static vpy_config_t g_config; +static PyObject *g_runtime = NULL; /* runtime.Runtime instance, strong reference */ + +PyObject *vpy_runtime(void) { return g_runtime; } + +#ifdef _WIN32 +/* malloc'ed wide string of a UTF-8 string, NULL on failure. */ +static wchar_t *to_wide(const char *text) { + int length = MultiByteToWideChar(CP_UTF8, 0, text, -1, NULL, 0); + wchar_t *wide; + + if (length <= 0) { + return NULL; + } + wide = (wchar_t *)malloc(sizeof(wchar_t) * (size_t)length); + if (wide != NULL) { + MultiByteToWideChar(CP_UTF8, 0, text, -1, wide, length); + } + return wide; +} +#endif + +/* + * Make the Python runtime library usable by extension modules. + * + * Windows: this library delay-loads pythonXY.dll. Load it by absolute path + * before the first Python API call so the delay-load helper finds it no + * matter how the simulator's DLL search path looks. + * + * POSIX: simulators dlopen() this library with RTLD_LOCAL, which makes + * libpython (our dependency) invisible to extension modules such as NumPy + * that expect the Python symbols to be globally available. Promote libpython + * to RTLD_GLOBAL. + */ +static int load_python_library(void) { +#ifdef _WIN32 + wchar_t *wpath = to_wide(g_config.python_dll); + HMODULE module; + + if (wpath == NULL) { + vpy_set_error("Invalid Python DLL path in the Python bridge configuration"); + return VPY_ERROR; + } + module = LoadLibraryExW(wpath, NULL, LOAD_WITH_ALTERED_SEARCH_PATH); + free(wpath); + if (module == NULL) { + unsigned long code = (unsigned long)GetLastError(); + size_t size = strlen(g_config.python_dll) + 80; + char *message = (char *)malloc(size); + + if (message == NULL) { + vpy_set_error("Failed to load the Python DLL"); + } else { + snprintf(message, size, "Failed to load the Python DLL %s (Windows error %lu)", g_config.python_dll, code); + vpy_set_error(message); + free(message); + } + return VPY_ERROR; + } + /* Intentionally never freed: the interpreter lives until process exit. */ + return VPY_OK; +#else + Dl_info info; + + if (dladdr((void *)&Py_InitializeFromConfig, &info) != 0 && info.dli_fname != NULL) { + /* Intentionally never closed: the interpreter lives until process exit. */ + if (dlopen(info.dli_fname, RTLD_NOW | RTLD_GLOBAL | RTLD_NOLOAD) == NULL) { + vpy_set_error2("Failed to make the Python library globally visible: ", dlerror()); + return VPY_ERROR; + } + } + return VPY_OK; +#endif +} + +static int set_config_string(PyConfig *config, wchar_t **field, const char *value) { + PyStatus status; +#ifdef _WIN32 + wchar_t *wvalue = to_wide(value); + + if (wvalue == NULL) { + vpy_set_error("Invalid path in the Python bridge configuration"); + return VPY_ERROR; + } + status = PyConfig_SetString(config, field, wvalue); + free(wvalue); +#else + status = PyConfig_SetBytesString(config, field, value); +#endif + if (PyStatus_Exception(status)) { + vpy_set_error2("Failed to configure Python: ", status.err_msg); + return VPY_ERROR; + } + return VPY_OK; +} + +/* Create the interpreter. On success this thread holds the GIL. */ +static int initialize_interpreter(void) { + PyConfig config; + PyStatus status; + + PyConfig_InitPythonConfig(&config); + /* The simulator owns the process: no signal handlers, no argv parsing. */ + config.install_signal_handlers = 0; + config.parse_argv = 0; + + /* Make path configuration behave as if the interpreter that launched VUnit + * was started, which selects the same installation, virtual environment + * and site-packages. */ + if (set_config_string(&config, &config.program_name, g_config.executable) != VPY_OK || + set_config_string(&config, &config.executable, g_config.executable) != VPY_OK) { + PyConfig_Clear(&config); + return VPY_ERROR; + } + + status = Py_InitializeFromConfig(&config); + PyConfig_Clear(&config); + if (PyStatus_Exception(status)) { + vpy_set_error2("Failed to initialize Python: ", status.err_msg != NULL ? status.err_msg : "unknown error"); + return VPY_ERROR; + } + return VPY_OK; +} + +/* Add a UTF-8 configuration string to a dict. GIL held. */ +static int set_item(PyObject *dict, const char *key, const char *value) { + PyObject *object = PyUnicode_DecodeUTF8(value, (Py_ssize_t)strlen(value), "surrogateescape"); + int status; + + if (object == NULL) { + return -1; + } + status = PyDict_SetItemString(dict, key, object); + Py_DECREF(object); + return status; +} + +/* Load ../runtime.py and create the Runtime instance. GIL held. */ +static int create_runtime(void) { + static const char *bootstrap = + "import importlib.util, sys\n" + "_spec = importlib.util.spec_from_file_location('_vunit_python_runtime', runtime_path)\n" + "_module = importlib.util.module_from_spec(_spec)\n" + "sys.modules[_spec.name] = _module\n" + "_spec.loader.exec_module(_module)\n" + "runtime = _module.Runtime(base_dir=base_dir, prefix=prefix)\n"; + PyObject *globals = PyDict_New(); + PyObject *ret = NULL; + int status = VPY_ERROR; + + if (globals == NULL || PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()) < 0 || + set_item(globals, "runtime_path", g_config.runtime) < 0 || set_item(globals, "base_dir", g_config.base_dir) < 0 || + set_item(globals, "prefix", g_config.prefix) < 0) { + vpy_set_error_from_python(); + goto done; + } + + ret = PyRun_String(bootstrap, Py_file_input, globals, globals); + if (ret == NULL) { + vpy_set_error_from_python(); + goto done; + } + + g_runtime = PyDict_GetItemString(globals, "runtime"); /* borrowed */ + if (g_runtime == NULL) { + vpy_set_error("Failed to create the Python bridge runtime"); + goto done; + } + Py_INCREF(g_runtime); + status = VPY_OK; + +done: + Py_XDECREF(ret); + Py_XDECREF(globals); + return status; +} + +/* + * Initialize on first use. Failure is sticky: the error text keeps the reason + * and every later operation fails with it. + */ +int vpy_initialize(void) { + int we_initialized; + PyGILState_STATE gil = PyGILState_UNLOCKED; + int status; + + if (g_state != STATE_UNINITIALIZED) { + return g_state == STATE_READY ? VPY_OK : VPY_ERROR; + } + g_state = STATE_FAILED; + + if (vpy_read_config(&g_config) != VPY_OK || load_python_library() != VPY_OK) { + return VPY_ERROR; + } + + /* Reuse an interpreter embedded by someone else in this process. */ + we_initialized = !Py_IsInitialized(); + if (we_initialized) { + if (initialize_interpreter() != VPY_OK) { + return VPY_ERROR; + } + } else { + gil = PyGILState_Ensure(); + } + + status = create_runtime(); + + if (we_initialized) { + /* Release the GIL taken by initialization. Every entry point acquires it + * with PyGILState_Ensure, from whatever thread the simulator happens to + * call us on. The thread state is kept forever. */ + PyEval_SaveThread(); + } else { + PyGILState_Release(gil); + } + + if (status == VPY_OK) { + g_state = STATE_READY; + } + return status; +} + +/* Whether the interpreter and the runtime are ready to be used. */ +int vpy_is_initialized(void) { return g_state == STATE_READY; } + +/* + * Common start of the entry points that use Python after vpy_begin: fail if + * the bridge is not initialized, else take the GIL. + */ +int vpy_enter(PyGILState_STATE *gil) { + if (g_state != STATE_READY) { + if (!vpy_has_error()) { + vpy_set_error("Internal error: the Python bridge is not initialized"); + } + return VPY_ERROR; + } + *gil = PyGILState_Ensure(); + return VPY_OK; +} + +/* Status of a Python call returning ret (a new reference or NULL), which is consumed. */ +int vpy_finish_call(PyObject *ret) { + if (ret == NULL) { + vpy_set_error_from_python(); + return VPY_ERROR; + } + Py_DECREF(ret); + return VPY_OK; +} diff --git a/vunit/python_bridge/native/operations.c b/vunit/python_bridge/native/operations.c new file mode 100644 index 000000000..6e5f09e38 --- /dev/null +++ b/vunit/python_bridge/native/operations.c @@ -0,0 +1,248 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + * + * The operations: setup, cleanup, begin, exec, eval, and the conversion and + * transfer of the result back to VHDL. + */ + +#include "bridge.h" + +#include +#include + +#define NUM_META 8 + +/* Result of the last evaluation, converted to a VHDL kind by vpy_eval. */ +static PyObject *g_result = NULL; /* bytes of a string/array result, strong reference */ +static const char *g_result_data = NULL; /* view into g_result, immutable */ +static size_t g_result_size = 0; +static int32_t g_result_integer = 0; +static double g_result_real = 0.0; +static int32_t g_result_meta[NUM_META]; + +/* Drop the result of the previous operation. GIL held. */ +static void clear_result(void) { + Py_CLEAR(g_result); + g_result_data = NULL; + g_result_size = 0; + g_result_integer = 0; + g_result_real = 0.0; + memset(g_result_meta, 0, sizeof(g_result_meta)); +} + +/* Keep the simulator's and Python's output in order. */ +static void flush_c_streams(void) { + fflush(stdout); + fflush(stderr); +} + +/* + * Start the interpreter. Eager and idempotent: later calls only re-run the + * runtime's setup, which does nothing but flush. Optional: every operation + * starts the interpreter if it is not running, see vpy_begin. + */ +VPY_EXPORT int32_t vpy_setup(void) { + PyGILState_STATE gil; + int status; + + flush_c_streams(); + if (vpy_initialize() != VPY_OK || vpy_enter(&gil) != VPY_OK) { + return VPY_ERROR; + } + status = vpy_finish_call(PyObject_CallMethod(vpy_runtime(), "setup", NULL)); + PyGILState_Release(gil); + return status; +} + +/* + * Release what the simulation acquired: flush Python's streams and drop the + * last result and the staged values. The interpreter is NOT finalized and the + * session namespaces are kept, so later operations keep working. Optional: + * everything it releases is released by the simulator process ending, and + * every operation flushes on its own. + */ +VPY_EXPORT int32_t vpy_cleanup(void) { + PyGILState_STATE gil; + int status; + + flush_c_streams(); + if (!vpy_is_initialized()) { + /* Nothing was ever started, so there is nothing to release. */ + return VPY_OK; + } + if (vpy_enter(&gil) != VPY_OK) { + return VPY_ERROR; + } + clear_result(); + vpy_clear_arguments(); + status = vpy_finish_call(PyObject_CallMethod(vpy_runtime(), "cleanup", NULL)); + PyGILState_Release(gil); + return status; +} + +/* + * Start a new operation in the session named by the buffer: initialize on + * first use, select the session and drop the previous result and values. + */ +VPY_EXPORT int32_t vpy_begin(void) { + PyGILState_STATE gil; + PyObject *session; + int status = VPY_ERROR; + + if (vpy_initialize() != VPY_OK || vpy_enter(&gil) != VPY_OK) { + return VPY_ERROR; + } + clear_result(); + vpy_clear_arguments(); + session = vpy_buffer_as_str(); + if (session == NULL) { + vpy_set_error_from_python(); + } else { + status = vpy_finish_call(PyObject_CallMethod(vpy_runtime(), "select_session", "O", session)); + Py_DECREF(session); + } + PyGILState_Release(gil); + return status; +} + +/* Execute the buffer as Python source (is_file = 0) or as a file name (is_file = 1). */ +VPY_EXPORT int32_t vpy_execute(int32_t is_file) { + PyGILState_STATE gil; + PyObject *text; + int status = VPY_ERROR; + + flush_c_streams(); + if (vpy_enter(&gil) != VPY_OK) { + return VPY_ERROR; + } + text = vpy_buffer_as_str(); + if (text == NULL) { + vpy_set_error_from_python(); + } else { + status = vpy_finish_call(PyObject_CallMethod(vpy_runtime(), "execute", "Oi", text, (int)(is_file != 0))); + Py_DECREF(text); + } + PyGILState_Release(gil); + /* The runtime flushed Python's streams, flush what Python wrote through C */ + flush_c_streams(); + return status; +} + +/* + * Store the conversion result (integer, real, bytes, meta tuple) returned by + * the runtime. Which fields are meaningful depends on the kind. GIL held. + */ +static int store_result(PyObject *converted) { + PyObject *data; + PyObject *meta; + Py_ssize_t index; + + if (!PyTuple_Check(converted) || PyTuple_Size(converted) != 4) { + vpy_set_error("Internal error: unexpected result conversion format"); + return VPY_ERROR; + } + data = PyTuple_GetItem(converted, 2); /* borrowed */ + meta = PyTuple_GetItem(converted, 3); /* borrowed */ + if (!PyBytes_Check(data) || !PyTuple_Check(meta)) { + vpy_set_error("Internal error: unexpected result conversion format"); + return VPY_ERROR; + } + + g_result_integer = (int32_t)PyLong_AsLong(PyTuple_GetItem(converted, 0)); + g_result_real = PyFloat_AsDouble(PyTuple_GetItem(converted, 1)); + for (index = 0; index < PyTuple_Size(meta) && index < NUM_META; index++) { + g_result_meta[index] = (int32_t)PyLong_AsLong(PyTuple_GetItem(meta, index)); + } + if (PyErr_Occurred()) { + vpy_set_error_from_python(); + return VPY_ERROR; + } + + Py_INCREF(data); + g_result = data; + g_result_data = PyBytes_AsString(data); + g_result_size = (size_t)PyBytes_Size(data); + return VPY_OK; +} + +/* + * Evaluate the buffer as a Python expression in the current session and + * convert the value to the requested VHDL kind and width. + */ +VPY_EXPORT int32_t vpy_eval(int32_t kind, int32_t width) { + PyGILState_STATE gil; + PyObject *text; + PyObject *converted; + int status = VPY_ERROR; + + flush_c_streams(); + if (vpy_enter(&gil) != VPY_OK) { + return VPY_ERROR; + } + clear_result(); + text = vpy_buffer_as_str(); + if (text == NULL) { + vpy_set_error_from_python(); + } else { + converted = PyObject_CallMethod(vpy_runtime(), "evaluate", "Oii", text, (int)kind, (int)width); + Py_DECREF(text); + if (converted == NULL) { + vpy_set_error_from_python(); + } else { + status = store_result(converted); + Py_DECREF(converted); + } + } + if (status != VPY_OK) { + clear_result(); + } + PyGILState_Release(gil); + /* The runtime flushed Python's streams, flush what Python wrote through C */ + flush_c_streams(); + return status; +} + +VPY_EXPORT int32_t vpy_result_integer(void) { return g_result_integer; } + +VPY_EXPORT double vpy_result_real(void) { return g_result_real; } + +VPY_EXPORT int32_t vpy_result_meta(int32_t index) { + if (index < 0 || index >= NUM_META) { + return 0; + } + return g_result_meta[index]; +} + +/* Copy length bytes of the string/bit-string result starting at offset. */ +VPY_EXPORT void vpy_result_read_string(char *chunk, int32_t offset, int32_t length) { + if (offset < 0 || length <= 0 || (size_t)offset + (size_t)length > g_result_size) { + return; + } + memcpy(chunk, g_result_data + offset, (size_t)length); +} + +/* Copy length integers of an integer_array_t/integer_vector result starting at element offset. */ +VPY_EXPORT void vpy_result_read_integers(int32_t *chunk, int32_t offset, int32_t length) { + size_t start = (size_t)offset * sizeof(int32_t); + size_t bytes = (size_t)length * sizeof(int32_t); + + if (offset < 0 || length <= 0 || start + bytes > g_result_size) { + return; + } + memcpy(chunk, g_result_data + start, bytes); +} + +/* Copy length reals of a real_vector result starting at element offset. */ +VPY_EXPORT void vpy_result_read_reals(double *chunk, int32_t offset, int32_t length) { + size_t start = (size_t)offset * sizeof(double); + size_t bytes = (size_t)length * sizeof(double); + + if (offset < 0 || length <= 0 || start + bytes > g_result_size) { + return; + } + memcpy(chunk, g_result_data + start, bytes); +} diff --git a/vunit/python_bridge/native_library.py b/vunit/python_bridge/native_library.py new file mode 100644 index 000000000..6538d257f --- /dev/null +++ b/vunit/python_bridge/native_library.py @@ -0,0 +1,380 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + + +""" +The native bridge library: compiled and cached on Linux, prebuilt DLLs on Windows. + +This module only depends on the standard library so that tools/build_python_bridge.py +can load it without VUnit's dependencies. +""" + +import hashlib +import os +import platform +import shlex +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path +from typing import List, Optional, Tuple + +PACKAGE_PATH = Path(__file__).parent.resolve() +# C sources of the bridge library +NATIVE_PATH = PACKAGE_PATH / "native" +# Prebuilt Windows DLLs, included in releases +BINARY_PATH = PACKAGE_PATH / "bin" +# Front end only compiled into the FLI variant of the library +FLI_SOURCE_NAME = "fli.c" + + +class PythonBridgeError(RuntimeError): + """ + The Python bridge cannot be set up in this environment, e.g. due to a missing prerequisite. + """ + + +def check_python_build() -> None: + """ + Reject Python builds that the bridge is known not to support. + """ + if sysconfig.get_config_var("Py_GIL_DISABLED"): + raise PythonBridgeError( + "VHDL Python support does not work with free-threaded CPython builds " + f"({sys.executable}). Use a regular (GIL) CPython build." + ) + if sys.implementation.name != "cpython": + raise PythonBridgeError(f"VHDL Python support requires CPython, not {sys.implementation.name}") + + +def library_file_name(fli: bool = False) -> str: + """ + File name of the bridge library built on POSIX platforms. + """ + return "libvunit_python_bridge_fli.so" if fli else "libvunit_python_bridge.so" + + +def prepare_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path: + """ + Path of the bridge library for the running Python, built or selected under root. + + :param simulator_prefix: Executable directory of a simulator calling the bridge through + the FLI (Questa/ModelSim). The FLI variant of the library, which + also contains native/fli.c, is then built against its headers. + None selects the VHPIDIRECT variant used by NVC and GHDL. + """ + if sys.platform == "win32": + if simulator_prefix is not None: + return _build_windows_fli_library(root, simulator_prefix) + return _prepare_windows_library(root) + return _prepare_posix_library(root, simulator_prefix) + + +def windows_dll_name(version_info: Optional[Tuple[int, ...]] = None) -> str: + """ + File name of the prebuilt bridge DLL for a Python version. + """ + major, minor = sys.version_info[:2] if version_info is None else version_info[:2] + return f"vunit_python_bridge-cp{major}{minor}-win_amd64.dll" + + +def windows_python_dll() -> str: + """ + Path of the Python DLL of the running interpreter. + """ + import ctypes # pylint: disable=import-outside-toplevel + + dll_handle = getattr(sys, "dllhandle", None) + if dll_handle is None: + raise PythonBridgeError( + f"VHDL Python support requires a CPython build using a Python DLL ({sys.executable} has none)" + ) + buffer = ctypes.create_unicode_buffer(32768) + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + length = kernel32.GetModuleFileNameW(ctypes.c_void_p(dll_handle), buffer, len(buffer)) + if length == 0: + raise PythonBridgeError("Failed to determine the path of the Python DLL") + return buffer.value + + +def _prepare_windows_library(root: Path) -> Path: + """ + Select the prebuilt DLL for the running Python. Never compiles. + """ + if sysconfig.get_platform() != "win-amd64": + raise PythonBridgeError( + "VHDL Python support on Windows requires a 64-bit (x86-64) python.org style CPython, " + f"{sys.executable} is built for {sysconfig.get_platform()}" + ) + if hasattr(sys, "gettotalrefcount"): + raise PythonBridgeError("VHDL Python support does not provide bridge DLLs for debug builds of CPython") + + name = windows_dll_name() + source = BINARY_PATH / name + if not source.is_file(): + raise PythonBridgeError( + f"No prebuilt Python bridge DLL for Python {sys.version_info[0]}.{sys.version_info[1]} " + f"({source!s} is missing). Released VUnit packages include the DLLs for the supported Python " + "versions. A development checkout can build them with tools/build_python_bridge.py (requires MSVC)." + ) + data = source.read_bytes() + directory = root / f"cp{sys.version_info[0]}{sys.version_info[1]}-win_amd64-{hashlib.sha256(data).hexdigest()[:12]}" + target = directory / "vunit_python_bridge.dll" + if not (target.is_file() and target.read_bytes() == data): + directory.mkdir(parents=True, exist_ok=True) + tmp = target.with_name(target.name + f".{os.getpid()}.tmp") + tmp.write_bytes(data) + os.replace(tmp, target) + return target + + +def _python_library() -> Path: + """ + The shared libpython of the running interpreter. + + Regular --enable-shared builds name it in LDLIBRARY (libpython3.X.so, .dylib); macOS + framework builds name the framework binary instead (Python.framework/Versions/3.X/Python). + """ + ldlibrary = sysconfig.get_config_var("LDLIBRARY") or "" + framework = sysconfig.get_config_var("PYTHONFRAMEWORK") or "" + version = f"{sys.version_info[0]}.{sys.version_info[1]}{getattr(sys, 'abiflags', '')}" + shared = bool(sysconfig.get_config_var("Py_ENABLE_SHARED")) or bool(framework) + if not shared or not (framework or ldlibrary.endswith((".so", ".dylib")) or ".so." in ldlibrary): + raise PythonBridgeError( + f"VHDL Python support requires a CPython built with a shared Python library (--enable-shared). " + f"{sys.executable} links Python statically " + f"(LDLIBRARY={ldlibrary!r}, Py_ENABLE_SHARED={sysconfig.get_config_var('Py_ENABLE_SHARED')!r})." + ) + prefix = Path(sys.base_prefix) + candidates = [Path(sysconfig.get_config_var("LIBDIR") or "") / ldlibrary] + # Relocated installations (e.g. uv/python-build-standalone) may report a + # build-time LIBDIR, fall back to the installation prefix. + candidates.append(prefix / "lib" / ldlibrary) + if sys.platform == "darwin": + candidates.append(prefix / "lib" / f"libpython{version}.dylib") + if framework: + # The framework binary: the prefix of a framework build is Versions/3.X + candidates.append(prefix / framework) + framework_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") or "" + candidates.append(Path(framework_prefix) / f"{framework}.framework" / "Versions" / version / framework) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + raise PythonBridgeError( + f"Failed to find the shared Python library {ldlibrary} of {sys.executable} " + f"(searched {', '.join(str(path) for path in candidates)})" + ) + + +def _include_dirs() -> List[str]: + """ + Include directories of the Python development headers. + """ + result = [] + for name in ("include", "platinclude"): + path = sysconfig.get_paths().get(name) + if path and path not in result: + result.append(path) + for path in result: + if (Path(path) / "Python.h").is_file(): + return result + raise PythonBridgeError( + f"VHDL Python support needs the Python development headers (Python.h) of {sys.executable} " + f"to build the Python bridge library, but they were not found in {', '.join(result)}. " + "Install the development package of your Python (e.g. python3-dev)." + ) + + +def _compiler() -> List[str]: + """ + The C compiler command: CC, the compiler Python was built with, or cc/gcc/clang. + """ + if os.environ.get("CC"): + return shlex.split(os.environ["CC"]) + configured = shlex.split(sysconfig.get_config_var("CC") or "") + if configured and shutil.which(configured[0]): + return configured[:1] + for name in ("cc", "gcc", "clang"): + if shutil.which(name): + return [name] + raise PythonBridgeError( + "VHDL Python support needs a C compiler (cc, gcc or clang) to build the Python bridge library. " + "Install one or set the CC environment variable." + ) + + +def bridge_sources(native_path: Optional[Path] = None, fli: bool = False) -> List[Path]: + """ + The C source files of the bridge library. fli adds the FLI front end. + """ + path = NATIVE_PATH if native_path is None else native_path + sources = sorted(item for item in path.glob("*.c") if item.name != FLI_SOURCE_NAME) + if fli: + sources.append(path / FLI_SOURCE_NAME) + return sources + + +def _fli_include_dir(simulator_prefix: Path) -> str: + """ + Directory of mti.h, next to the executable directory of the simulator. + """ + include = Path(simulator_prefix).resolve().parent / "include" + if not (include / "mti.h").is_file(): + raise PythonBridgeError( + f"VHDL Python support needs the FLI header mti.h of the simulator to build the Python " + f"bridge library, but it was not found in {include!s}" + ) + return str(include) + + +def _source_fingerprint() -> str: + """ + Hash of all bridge sources and headers. + """ + digest = hashlib.sha256() + for path in sorted(NATIVE_PATH.glob("*.[ch]")): + digest.update(path.name.encode("utf-8") + b"\0" + path.read_bytes() + b"\0") + return digest.hexdigest() + + +def _posix_cache_directory(root: Path, python_library: Path, include_dirs: List[str]) -> Path: + """ + Cache directory of a build: everything it depends on hashed into its name. The + include directories cover the FLI variant, whose simulator headers are among them. + """ + key_items = [ + _source_fingerprint(), + sys.platform, + platform.machine(), + " ".join(platform.libc_ver()), + sys.version, + str(sysconfig.get_config_var("SOABI")), + str(sysconfig.get_config_var("Py_GIL_DISABLED")), + str(python_library), + " ".join(include_dirs), + ] + key = hashlib.sha256("\n".join(key_items).encode("utf-8")).hexdigest()[:16] + tag = sysconfig.get_config_var("SOABI") or f"cp{sys.version_info[0]}{sys.version_info[1]}" + return root / f"{tag}-{key}" + + +def _prepare_posix_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path: + """ + Compile the bridge for the running Python, reusing a cached build when possible. + + The FLI variant, selected with simulator_prefix, differs only in the added front end, + the added include directory and the file name, so both variants share this cache: the + simulator prefix is part of the key through its include directory. + """ + fli = simulator_prefix is not None + python_library = _python_library() + include_dirs = _include_dirs() + if simulator_prefix is not None: + include_dirs = include_dirs + [_fli_include_dir(simulator_prefix)] + + name = library_file_name(fli) + directory = _posix_cache_directory(root, python_library, include_dirs) + library_file = directory / name + if library_file.is_file(): + return library_file + + compiler = _compiler() + directory.mkdir(parents=True, exist_ok=True) + tmp = directory / f"{name}.{os.getpid()}.tmp" + cmd = ( + compiler + + ["-shared", "-fPIC", "-O2", "-fvisibility=hidden"] + + [f"-I{path}" for path in include_dirs] + + [str(path) for path in bridge_sources(fli=fli)] + + ["-o", str(tmp)] + + [str(python_library), f"-Wl,-rpath,{python_library.parent!s}"] + + ([f"-Wl,-install_name,@rpath/{name}"] if sys.platform == "darwin" else [f"-Wl,-soname,{name}"]) + + (["-ldl"] if sys.platform.startswith("linux") else []) + ) + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) + except OSError as exc: + raise PythonBridgeError(f"Failed to run the C compiler {compiler[0]!r}: {exc}") from exc + if proc.returncode != 0: + tmp.unlink(missing_ok=True) + raise PythonBridgeError( + "Failed to build the Python bridge library:\n" + + " ".join(shlex.quote(item) for item in cmd) + + "\n" + + proc.stdout.decode(errors="replace") + ) + os.replace(tmp, library_file) + return library_file + + +def _questa_mingw_gcc(simulator_prefix: Path) -> str: + """ + The MinGW gcc bundled with Questa/ModelSim, used for FLI applications on Windows. + """ + matches = sorted(simulator_prefix.parent.glob("gcc*mingw64*")) + for match in matches: + gcc = match / "bin" / "gcc.exe" + if gcc.is_file(): + return str(gcc.resolve()) + raise PythonBridgeError( + "VHDL Python support on Windows needs the MinGW GCC bundled with Questa/ModelSim to build " + f"the Python bridge library, but it was not found in {simulator_prefix.parent!s}" + ) + + +def _build_windows_fli_library(root: Path, simulator_prefix: Path) -> Path: + """ + Build the FLI variant on Windows with the MinGW gcc bundled with Questa/ModelSim, against the + headers and the import library of the Python running VUnit. The prebuilt DLLs cannot be used: + they are MSVC builds without the FLI front end, which has to be linked against the simulator's + own libmtipli. Reuses a cached build like the POSIX one. + + Untested: this environment has no Windows installation of Questa. + """ + simulator_prefix = Path(simulator_prefix).resolve() + include = _fli_include_dir(simulator_prefix) + gcc = _questa_mingw_gcc(simulator_prefix) + python_home = Path(sys.executable).parent.resolve() + python_include = python_home / "include" + python_libs = python_home / "libs" + if not (python_include / "Python.h").is_file(): + raise PythonBridgeError( + f"VHDL Python support needs the Python development headers (Python.h) of {sys.executable} " + f"to build the Python bridge library, but they were not found in {python_include!s}" + ) + + key_items = [_source_fingerprint(), sys.version, str(python_home), str(simulator_prefix), gcc] + key = hashlib.sha256("\n".join(key_items).encode("utf-8")).hexdigest()[:16] + directory = root / f"cp{sys.version_info[0]}{sys.version_info[1]}-win_amd64-fli-{key}" + library_file = directory / "vunit_python_bridge_fli.dll" + if library_file.is_file(): + return library_file + + directory.mkdir(parents=True, exist_ok=True) + tmp = directory / f"vunit_python_bridge_fli.dll.{os.getpid()}.tmp" + cmd = ( + [gcc, "-shared", "-m64", "-O2", "-D__USE_MINGW_ANSI_STDIO=1", "-freg-struct-return"] + + [f"-I{include}", f"-I{python_include!s}"] + + [str(path) for path in bridge_sources(fli=True)] + + ["-o", str(tmp)] + + [f"-L{python_libs!s}", f"-lpython{sys.version_info[0]}{sys.version_info[1]}"] + + [f"-L{simulator_prefix!s}", "-lmtipli"] + ) + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) + except OSError as exc: + raise PythonBridgeError(f"Failed to run the C compiler {gcc!r}: {exc}") from exc + if proc.returncode != 0: + tmp.unlink(missing_ok=True) + raise PythonBridgeError( + "Failed to build the Python bridge library:\n" + + " ".join(shlex.quote(item) for item in cmd) + + "\n" + + proc.stdout.decode(errors="replace") + ) + os.replace(tmp, library_file) + return library_file diff --git a/vunit/python_bridge/runtime.py b/vunit/python_bridge/runtime.py new file mode 100644 index 000000000..f8a499a76 --- /dev/null +++ b/vunit/python_bridge/runtime.py @@ -0,0 +1,701 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Runtime of the Python bridge. + +This module is executed *inside the simulator process* by the embedded +interpreter of the native bridge (native/*.c). It is loaded by file +path, not imported from the vunit package, and must therefore not import +anything from vunit. It is a private implementation detail of the VHDL +exec/eval operations. +""" + +import builtins +import linecache +import math +import numbers +import os +import struct +import sys +import traceback +from pathlib import Path +import __main__ + +# Result kinds, must match python_ffi_pkg +KIND_INTEGER = 0 +KIND_REAL = 1 +KIND_BOOLEAN = 2 +KIND_STRING = 3 +KIND_STD_LOGIC = 4 +KIND_STD_LOGIC_VECTOR = 5 +KIND_SIGNED = 6 +KIND_UNSIGNED = 7 +KIND_INTEGER_ARRAY = 8 +KIND_INTEGER_VECTOR = 9 +KIND_REAL_VECTOR = 10 + +VHDL_TYPE_NAMES = { + KIND_INTEGER: "integer", + KIND_REAL: "real", + KIND_BOOLEAN: "boolean", + KIND_STRING: "string", + KIND_STD_LOGIC: "std_logic", + KIND_STD_LOGIC_VECTOR: "std_logic_vector", + KIND_SIGNED: "signed", + KIND_UNSIGNED: "unsigned", + KIND_INTEGER_ARRAY: "integer_array_t", + KIND_INTEGER_VECTOR: "integer_vector", + KIND_REAL_VECTOR: "real_vector", +} + +STD_LOGIC_VALUES = frozenset("UX01ZWLH-") +INTEGER_LOW = -(2**31) +INTEGER_HIGH = 2**31 - 1 +TEXT_ENCODING = "utf-8" +TEXT_ERRORS = "surrogateescape" + +DEFAULT_SESSION = "default" + +# Name of the bridge object injected into every session namespace. +HANDLE_NAME = "__vunit__" + +_NO_VALUE = object() + + +def _type_name(value): + return type(value).__qualname__ + + +def _is_bool(value): + """True for Python and NumPy booleans.""" + if isinstance(value, bool): + return True + numpy = sys.modules.get("numpy") + return numpy is not None and isinstance(value, numpy.bool_) + + +def _is_float(value): + """ + True for Python and NumPy floats only. + + An int is not accepted, matching the PyFloat_Check of the other + python_ffi_pkg implementations. + """ + if isinstance(value, float): + return True + numpy = sys.modules.get("numpy") + return numpy is not None and isinstance(value, numpy.floating) + + +def _is_integer(value): + """True for Python and NumPy integers, but not for booleans.""" + return not _is_bool(value) and isinstance(value, numbers.Integral) + + +def _encode(text): + return text.encode(TEXT_ENCODING, TEXT_ERRORS) + + +def _type_error(kind, value, expected): + """ + The error raised when a Python value cannot become the requested VHDL type. + + The operation it belongs to (exec, eval, call, ...) is added by VHDL. + """ + return TypeError( + f"Cannot convert Python {_type_name(value)} ({value!r:.200}) " + f"to VHDL {VHDL_TYPE_NAMES[kind]}; expected {expected}" + ) + + +class StagedValues: + """ + Values transferred from VHDL and kept under an id. + + VHDL transfers an integer_array_t once and then refers to it as + ``__vunit__.staged()`` in the Python expressions it evaluates. Staged + values live until the simulation ends, or until python_cleanup releases + them early, so that one VHDL constant can be used in several calls. Every + use gets its own copy of the NumPy array so that a function modifying it in + place does not affect the next use. + """ + + def __init__(self): + self._values = {} # id -> (value, bit_width, is_signed) + + def stage(self, value, bit_width, is_signed): + """ + Stage a value and return its id. + """ + staged_id = len(self._values) + 1 + self._values[staged_id] = (value, bit_width, bool(is_signed)) + return staged_id + + def get(self, staged_id): + """ + A copy of a staged value with its metadata, as (value, bit_width, is_signed). + """ + entry = self._values.get(staged_id) + if entry is None: + raise ValueError(f"There is no value staged under the id {staged_id!r}") + value, bit_width, is_signed = entry + numpy = sys.modules.get("numpy") + if numpy is not None and isinstance(value, numpy.ndarray): + # The user code may modify it in place, keep the staged value intact + value = value.copy() + return (value, bit_width, is_signed) + + def clear(self): + """ + Drop all staged values. + """ + self._values.clear() + + +class BridgeHandle: + """ + The ``__vunit__`` object that VHDL-generated Python expressions use. + + It is the only name the bridge adds to a session namespace. + """ + + def __init__(self, runtime): + self._runtime = runtime + + def staged(self, staged_id): + """ + The value VHDL staged under the given id. + """ + return self._runtime.staged(staged_id) + + def __repr__(self): + return "" + + +class Runtime: # pylint: disable=too-many-instance-attributes + """ + State of the embedded Python session: one persistent namespace per session, + shared by all exec and eval operations of a simulation. + """ + + def __init__(self, base_dir, prefix): + self._base_dir = Path(base_dir) + self._handle = BridgeHandle(self) + # One namespace per session. The default session uses __main__. + self._sessions = {DEFAULT_SESSION: __main__.__dict__} + __main__.__dict__[HANDLE_NAME] = self._handle + self._session = DEFAULT_SESSION + self._exec_count = 0 + self._eval_count = 0 + self._result = _NO_VALUE + # Metadata of the integer_array_t values handed to the current + # operation, keyed by id() of the NumPy array created for them. + self._array_meta = {} + self._staged = StagedValues() + + self._check_environment(prefix) + + # Mimic "python run.py": the run script directory is sys.path[0]. + if str(self._base_dir) not in sys.path: + sys.path.insert(0, str(self._base_dir)) + + @staticmethod + def _check_environment(prefix): + """ + The embedded interpreter must use the Python environment that launched VUnit. + """ + + def normalize(path): + return os.path.normcase(os.path.realpath(path)) + + if normalize(sys.prefix) != normalize(prefix): + raise RuntimeError( + f"The embedded Python interpreter selected the environment {sys.prefix!r} " + f"but VUnit was started from {prefix!r}" + ) + + @staticmethod + def _flush(): + """ + Flush Python's output so that it is ordered with the simulator's output. + """ + for stream in (sys.stdout, sys.stderr): + try: + if stream is not None: + stream.flush() + except Exception: # pylint: disable=broad-except + pass + + def format_exception(self, exc): + """ + Format an exception with its traceback, hiding frames of this module. + """ + this_file = os.path.normcase(os.path.abspath(__file__)) + traceback_ = exc.__traceback__ + while traceback_ is not None and ( + os.path.normcase(os.path.abspath(traceback_.tb_frame.f_code.co_filename)) == this_file + ): + traceback_ = traceback_.tb_next + self._flush() + return "".join(traceback.format_exception(type(exc), exc, traceback_)).rstrip("\n") + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def setup(self): + """ + Prepare for the operations to come. Idempotent: the interpreter and the + namespaces are created once and python_setup may be called any number + of times. Calling it at all is optional since every operation starts + the interpreter if it is not running. + """ + self._flush() + + def cleanup(self): + """ + Release what the simulation acquired: the last result and the staged + values. The interpreter and the session namespaces are kept, so + operations after python_cleanup keep working. Calling it at all is + optional: it only releases these values earlier than the end of the + simulator process, and every operation flushes on its own. + """ + self._result = _NO_VALUE + self._staged.clear() + self._array_meta.clear() + self._flush() + + def select_session(self, name): + """ + Make the namespace of a session current, creating it on first use. + """ + if name not in self._sessions: + self._sessions[name] = { + "__name__": "__main__", + "__builtins__": builtins, + HANDLE_NAME: self._handle, + } + self._session = name + self._result = _NO_VALUE + self._array_meta = {} + + @property + def _namespace(self): + """ + Namespace of the current session. + """ + return self._sessions[self._session] + + def _source_name(self, operation, count): + """ + Pseudo file name of inline source code, shown in tracebacks. + """ + if self._session == DEFAULT_SESSION: + return f"<{operation} #{count}>" + return f"<{operation} {self._session} #{count}>" + + # ------------------------------------------------------------------ + # exec + # ------------------------------------------------------------------ + + def execute(self, text, is_file): + """ + Execute inline source code or a Python file in the persistent namespace. + """ + try: + if is_file: + self._execute_file(text) + else: + self._execute_source(text) + finally: + self._flush() + + def _execute_source(self, source): + """ + Execute inline source code, registered in linecache for readable tracebacks. + """ + self._exec_count += 1 + file_name = self._source_name("exec", self._exec_count) + # Make tracebacks show the source lines of inline code + linecache.cache[file_name] = (len(source), None, source.splitlines(True), file_name) + code = compile(source, file_name, "exec", dont_inherit=True) + exec(code, self._namespace, self._namespace) # pylint: disable=exec-used + + def _resolve_file(self, file_name): + """ + Absolute path of a Python file, relative names are relative to the run script directory. + """ + if file_name == "": + raise ValueError("Empty Python file name") + path = Path(file_name) + if not path.is_absolute(): + path = self._base_dir / path + return Path(os.path.normpath(path.absolute())) + + def _execute_file(self, file_name): + """ + Execute a Python file with __file__ set and its directory on sys.path. + """ + path = self._resolve_file(file_name) + with open(path, encoding="utf-8") as fptr: + source = fptr.read() + code = compile(source, str(path), "exec", dont_inherit=True) + + namespace = self._namespace + previous_file = namespace.get("__file__", _NO_VALUE) + directory = str(path.parent) + namespace["__file__"] = str(path) + # Let the file import its sibling modules + sys.path.insert(0, directory) + try: + exec(code, namespace, namespace) # pylint: disable=exec-used + finally: + try: + sys.path.remove(directory) + except ValueError: + pass + if previous_file is _NO_VALUE: + namespace.pop("__file__", None) + else: + namespace["__file__"] = previous_file + + # ------------------------------------------------------------------ + # eval + # ------------------------------------------------------------------ + + def evaluate(self, text, kind, width): + """ + Evaluate a Python expression in the session namespace and convert the + value to the VHDL type given by kind. + + :param width: Width of a std_logic_vector/signed/unsigned result, -1 if not given by VHDL. + :returns: (integer, real, data bytes, metadata tuple) + """ + self._result = _NO_VALUE + self._array_meta = {} + self._eval_count += 1 + file_name = self._source_name("eval", self._eval_count) + try: + # Make tracebacks show the source lines of the expression + linecache.cache[file_name] = (len(text), None, text.splitlines(True), file_name) + code = compile(text, file_name, "eval", dont_inherit=True) + # Running the user's own Python code is the purpose of this module + self._result = eval(code, self._namespace, self._namespace) # pylint: disable=eval-used + except BaseException: + self._array_meta = {} + self._flush() + raise + self._flush() + return self.convert_result(kind, width) + + # ------------------------------------------------------------------ + # Values transferred from VHDL + # ------------------------------------------------------------------ + + def staged(self, staged_id): + """ + The value staged under an id, used from Python as __vunit__.staged(id). + """ + value, bit_width, is_signed = self._staged.get(staged_id) + numpy = sys.modules.get("numpy") + if numpy is not None and isinstance(value, numpy.ndarray): + # An expression returning this very array keeps its word size + self._array_meta[id(value)] = (value, bit_width, is_signed) + return value + + def stage_value(self, value): + """ + Stage a value transferred from VHDL and return its id. + """ + meta = self._array_meta.get(id(value)) + if meta is not None and meta[0] is value: + bit_width, is_signed = meta[1], meta[2] + else: + bit_width, is_signed = 32, True + return self._staged.stage(value, bit_width, is_signed) + + @staticmethod + def _numpy(): + """ + Import NumPy, which is only needed when integer_array_t values are exchanged. + """ + try: + import numpy # type: ignore[import-not-found] # pylint: disable=import-outside-toplevel + except ImportError as exc: + raise ImportError( + "integer_array_t values are exchanged as NumPy arrays but NumPy could not be imported " + f"in the Python environment used by VUnit ({sys.executable}): {exc}" + ) from exc + return numpy + + @staticmethod + def _shape(length, width, height, depth): + """ + NumPy shape of an integer_array_t. get(arr, x, y) is a[y, x] and + get(arr, x, y, z) is a[y, x, z], matching VUnit's storage order. + """ + if depth > 1: + return (height, width, depth) + if height > 1: + return (height, width) + return (length,) + + def make_array( + self, storage, length, width, height, depth, bit_width, is_signed + ): # pylint: disable=too-many-arguments,too-many-positional-arguments + """ + Create the NumPy array for an integer_array_t value. The bridge + fills the storage (native int32) after this call returns. + """ + numpy = self._numpy() + array = numpy.frombuffer(storage, dtype=numpy.int32).reshape(self._shape(length, width, height, depth)) + self._array_meta[id(array)] = (array, bit_width, bool(is_signed)) + return array + + # ------------------------------------------------------------------ + # Results + # ------------------------------------------------------------------ + + def convert_result(self, kind, width): + """ + Convert the value of the last evaluation to the VHDL type given by kind. + + :param width: Width of a std_logic_vector/signed/unsigned result, -1 if not given by VHDL. + :returns: (integer, real, data bytes, metadata tuple) + """ + value = self._result + self._result = _NO_VALUE + array_meta = self._array_meta + self._array_meta = {} + if value is _NO_VALUE: + raise RuntimeError("Internal error: no Python result available") + + converters = { + KIND_INTEGER: self._integer_result, + KIND_REAL: self._real_result, + KIND_BOOLEAN: self._boolean_result, + KIND_STRING: self._string_result, + KIND_STD_LOGIC: self._std_logic_result, + KIND_STD_LOGIC_VECTOR: self._std_logic_vector_result, + KIND_SIGNED: self._bits_result, + KIND_UNSIGNED: self._bits_result, + KIND_INTEGER_VECTOR: self._integer_vector_result, + KIND_REAL_VECTOR: self._real_vector_result, + } + if kind == KIND_INTEGER_ARRAY: + return self._array_result(value, array_meta) + if kind not in converters: + raise RuntimeError(f"Internal error: unknown result kind {kind}") + return converters[kind](kind, value, width) + + @staticmethod + def _integer_result(kind, value, _width): + """ + Convert an int result. + """ + if not _is_integer(value): + raise _type_error(kind, value, "int") + value = int(value) + if not INTEGER_LOW <= value <= INTEGER_HIGH: + raise OverflowError(f"{value} is outside the range of VHDL integer ({INTEGER_LOW} to {INTEGER_HIGH})") + return (value, 0.0, b"", ()) + + @staticmethod + def _real_result(kind, value, _width): + """ + Convert a float result. An int is not a float, like in Python's C API. + """ + if not _is_float(value): + raise _type_error(kind, value, "float") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{result} cannot be represented as VHDL real") + return (0, result, b"", ()) + + @staticmethod + def _boolean_result(kind, value, _width): + """ + Convert a bool result. + """ + if not _is_bool(value): + raise _type_error(kind, value, "bool") + return (int(bool(value)), 0.0, b"", ()) + + @staticmethod + def _string_result(kind, value, _width): + """ + Convert a str result to UTF-8. + """ + if not isinstance(value, str): + raise _type_error(kind, value, "str") + data = _encode(value) + return (0, 0.0, data, (len(data),)) + + @staticmethod + def _std_logic_result(kind, value, _width): + """ + Convert a one character str result. + """ + if not isinstance(value, str) or len(value) != 1 or value not in STD_LOGIC_VALUES: + raise _type_error(kind, value, "a one character str, one of 'UX01ZWLH-'") + return (0, 0.0, value.encode("ascii"), (1,)) + + @staticmethod + def _std_logic_vector_result(kind, value, width): + """ + Convert a str result, one character per element. + """ + if not isinstance(value, str) or not STD_LOGIC_VALUES.issuperset(value): + raise _type_error(kind, value, "a str of the characters 'UX01ZWLH-'") + if width >= 0 and len(value) != width: + raise ValueError(f"Got {len(value)} std_logic values but the VHDL result has length {width}") + return (0, 0.0, value.encode("ascii"), (len(value),)) + + def _bits_result(self, kind, value, width): + """ + Convert an int result to the bits of a signed/unsigned. + """ + data = self._int_to_bits(kind, value, width) + return (0, 0.0, data, (len(data),)) + + @staticmethod + def _int_to_bits(kind, value, width): + """ + Two's complement/binary image of an int, checked to fit width. + """ + if not _is_integer(value): + raise _type_error(kind, value, "int") + value = int(value) + is_signed = kind == KIND_SIGNED + if is_signed: + low, high = (-(1 << (width - 1)), (1 << (width - 1)) - 1) if width > 0 else (0, -1) + else: + low, high = 0, (1 << width) - 1 + if not low <= value <= high: + raise OverflowError(f"{value} does not fit in a {width} bit {VHDL_TYPE_NAMES[kind]} ({low} to {high})") + if width == 0: + return b"" + return format(value & ((1 << width) - 1), f"0{width}b").encode("ascii") + + @staticmethod + def _sequence(kind, value): + """ + The elements of a list/tuple result. Nothing else is accepted, like in + the other python_ffi_pkg implementations. + """ + if not isinstance(value, (list, tuple)): + raise _type_error(kind, value, "a list or tuple") + return value + + def _integer_vector_result(self, kind, value, _width): + """ + Convert a list/tuple of int to integer_vector data (native int32). + """ + values = self._sequence(kind, value) + integers = [] + for index, element in enumerate(values): + if not _is_integer(element): + raise TypeError( + f"Cannot convert element {index} of the Python {_type_name(value)}, " + f"a {_type_name(element)} ({element!r:.100}), to a VHDL integer; expected int" + ) + element = int(element) + if not INTEGER_LOW <= element <= INTEGER_HIGH: + raise OverflowError( + f"Element {index} ({element}) is outside the range of VHDL integer " + f"({INTEGER_LOW} to {INTEGER_HIGH})" + ) + integers.append(element) + data = struct.pack(f"={len(integers)}i", *integers) + return (0, 0.0, data, (len(integers),)) + + def _real_vector_result(self, kind, value, _width): + """ + Convert a list/tuple of float to real_vector data (native float64). + """ + values = self._sequence(kind, value) + reals = [] + for index, element in enumerate(values): + if not _is_float(element): + raise TypeError( + f"Cannot convert element {index} of the Python {_type_name(value)}, " + f"a {_type_name(element)} ({element!r:.100}), to a VHDL real; expected float" + ) + element = float(element) + if not math.isfinite(element): + raise ValueError(f"Element {index} ({element}) cannot be represented as VHDL real") + reals.append(element) + data = struct.pack(f"={len(reals)}d", *reals) + return (0, 0.0, data, (len(reals),)) + + _DTYPE_WORD_SIZE = { + # dtype.str without byte order: (bit_width, is_signed) + "b1": (1, False), + "i1": (8, True), + "u1": (8, False), + "i2": (16, True), + "u2": (16, False), + "i4": (32, True), + } + + def _array_result(self, value, array_meta): + """ + Convert a NumPy array (or nested int sequence) to integer_array_t data. + """ + numpy = self._numpy() + original = value + value = self._integer_ndarray(numpy, value) + bit_width, is_signed = self._word_size(original, value, array_meta) + + low, high = (-(1 << (bit_width - 1)), (1 << (bit_width - 1)) - 1) if is_signed else (0, (1 << bit_width) - 1) + if value.size > 0: + minimum, maximum = int(value.min()), int(value.max()) + if minimum < low or maximum > high: + raise OverflowError( + f"The array values ({minimum} to {maximum}) do not fit in " + f"the integer_array_t word size ({bit_width} bit {'signed' if is_signed else 'unsigned'}, " + f"{low} to {high})" + ) + + # get(arr, x, y, z) is value[y, x, z] + height, width, depth = (value.shape + (1, 1))[:3] if value.ndim > 1 else (1, value.shape[0], 1) + + data = numpy.ascontiguousarray(value, dtype=numpy.int32).tobytes() + return (0, 0.0, data, (value.size, width, height, depth, bit_width, int(is_signed))) + + @staticmethod + def _integer_ndarray(numpy, value): + """ + The result as an integer NumPy array with 1 to 3 dimensions. + """ + if not isinstance(value, numpy.ndarray): + if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)): + raise _type_error(KIND_INTEGER_ARRAY, value, "a NumPy array of integers") + value = numpy.asarray(value) + + if value.dtype.kind not in "biu": + raise TypeError( + f"Cannot convert a NumPy array of dtype {value.dtype} to a VHDL integer_array_t; " + "expected an integer or boolean dtype" + ) + if value.ndim not in (1, 2, 3): + raise ValueError( + f"Cannot convert a {value.ndim}-dimensional NumPy array to a VHDL integer_array_t; " + "expected 1, 2 or 3 dimensions" + ) + return value + + def _word_size(self, original, value, array_meta): + """ + bit_width and is_signed of a returned array. + """ + meta = array_meta.get(id(original)) + if meta is not None and meta[0] is original and meta[1] >= 1: + # The expression returned (possibly modified in place) one of the + # integer_array_t values VHDL staged: keep its bit width and signedness. + return meta[1], meta[2] + return self._DTYPE_WORD_SIZE.get(value.dtype.str[1:], (32, True)) diff --git a/vunit/python_bridge/simulator_hooks.py b/vunit/python_bridge/simulator_hooks.py new file mode 100644 index 000000000..eadda950f --- /dev/null +++ b/vunit/python_bridge/simulator_hooks.py @@ -0,0 +1,72 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + + +""" +Hooks used by the NVC, GHDL and ModelSim/Questa interfaces to make the simulator load the +bridge library and its dependencies. They have no effect unless Python support is enabled +for the project. + +Questa needs no help finding the library itself: the FLI attributes of the generated +python_bridge_pkg.vhd name it by absolute path. +""" + +import os +import sys +from typing import Dict, List + +from .bridge import GHDL_LINKING_BACKENDS, get_bridge + + +def nvc_run_flags(project) -> List[str]: + """ + NVC run (-r) flags loading the bridge. Empty unless Python support is enabled. + """ + bridge = get_bridge(project) + if bridge is None: + return [] + return [f"--load={bridge.library_file!s}"] + + +def ghdl_elab_flags(project, backend) -> List[str]: + """ + GHDL elaboration flags. Only ahead-of-time linking backends need them. + """ + bridge = get_bridge(project) + if bridge is None or backend not in GHDL_LINKING_BACKENDS: + return [] + return [f"-Wl,-L{bridge.directory!s}"] + + +def ghdl_run_env(project, env: Dict[str, str]) -> Dict[str, str]: + """ + Add the bridge directory to the dynamic library search path of a GHDL simulation. + """ + bridge = get_bridge(project) + if bridge is None: + return env + variable = {"win32": "PATH", "darwin": "DYLD_LIBRARY_PATH"}.get(sys.platform, "LD_LIBRARY_PATH") + env = dict(env) + env[variable] = os.pathsep.join(item for item in (str(bridge.directory), env.get(variable, "")) if item) + return env + + +def modelsim_vsim_flags(project) -> List[str]: + """ + Flags for the vsim processes VUnit starts itself. + + Questa/ModelSim puts the directory of the C++ runtime it bundles first in + LD_LIBRARY_PATH. That runtime is regularly older than the one the Python extension + modules of the environment (NumPy, used for integer_array_t values) were built + against, and they then fail to load in the embedded interpreter. -noautoldlibpath + turns that off, leaving the C++ runtime of the system to be found as usual. + + Only relevant on Linux, where LD_LIBRARY_PATH decides this, and only when the + simulator accepts the flag. + """ + if get_bridge(project) is None or not sys.platform.startswith("linux"): + return [] + return ["-noautoldlibpath"] diff --git a/vunit/sim_if/__init__.py b/vunit/sim_if/__init__.py index 9e22f803b..41238f698 100644 --- a/vunit/sim_if/__init__.py +++ b/vunit/sim_if/__init__.py @@ -94,6 +94,13 @@ def supports_vhdl_call_paths(cls): """ return False + @classmethod + def supported_foreign_language_interfaces(cls): + """ + Returns set of supported foreign interfaces + """ + return set() + @staticmethod def find_executable(executable): """ diff --git a/vunit/sim_if/activehdl.py b/vunit/sim_if/activehdl.py index 448ccb340..be06aa60e 100644 --- a/vunit/sim_if/activehdl.py +++ b/vunit/sim_if/activehdl.py @@ -60,6 +60,13 @@ def supports_vhdl_call_paths(cls): """ return True + @classmethod + def supported_foreign_language_interfaces(cls): + """ + Returns set of supported foreign interfaces + """ + return set(["VHPI"]) + @classmethod def supports_vhdl_package_generics(cls): """ diff --git a/vunit/sim_if/ghdl.py b/vunit/sim_if/ghdl.py index c23d5ed9d..9334b35d5 100644 --- a/vunit/sim_if/ghdl.py +++ b/vunit/sim_if/ghdl.py @@ -22,6 +22,7 @@ from . import SimulatorInterface, ListOfStringOption, StringOption, BooleanOption from . import check_executable from ..vhdl_standard import VHDL +from ..python_bridge import simulator_hooks from ._viewermixin import ViewerMixin LOGGER = logging.getLogger(__name__) @@ -199,6 +200,13 @@ def supports_vhdl_package_generics(cls): """ return True + @classmethod + def supported_foreign_language_interfaces(cls): + """ + Returns set of supported foreign interfaces + """ + return set(["VHPIDIRECT_GHDL"]) + @classmethod def supports_vhpi(cls): """ @@ -320,6 +328,7 @@ def _get_command( if self._has_output_flag(): cmd += ["-o", bin_path] cmd += config.sim_options.get("ghdl.elab_flags", []) + cmd += simulator_hooks.ghdl_elab_flags(self._project, self._backend) if config.sim_options.get("enable_coverage", False): if self._backend == "gcc": # Enable coverage in linker @@ -392,7 +401,7 @@ def simulate(self, output_path, test_suite_name, config, elaborate_only): # pyl status = True - gcov_env = environ.copy() + gcov_env = simulator_hooks.ghdl_run_env(self._project, environ.copy()) if config.sim_options.get("enable_coverage", False): if self._backend == "gcc": # Set environment variable to put the coverage output in the test_output folder diff --git a/vunit/sim_if/modelsim.py b/vunit/sim_if/modelsim.py index d0ee6a13a..0babc59f1 100644 --- a/vunit/sim_if/modelsim.py +++ b/vunit/sim_if/modelsim.py @@ -19,6 +19,7 @@ from ..vhdl_standard import VHDL from . import SimulatorInterface, ListOfStringOption, StringOption, BooleanOption, check_output from .vsim_simulator_mixin import VsimSimulatorMixin, fix_path +from ..python_bridge import simulator_hooks LOGGER = logging.getLogger(__name__) @@ -159,6 +160,13 @@ def supports_vhdl_call_paths(cls): """ return True + @classmethod + def supported_foreign_language_interfaces(cls): + """ + Returns set of supported foreign interfaces + """ + return set(["FLI"]) + @classmethod def supports_vhdl_package_generics(cls): """ @@ -228,6 +236,11 @@ def add_simulator_specific(self, project): """ Add libraries from INI file and add coverage flags """ + self._vsim_process_flags[:] = [ + flag + for flag in simulator_hooks.modelsim_vsim_flags(project) + if self._find_in_help(self._prefix, "vsim", flag) + ] mapped_libraries = self._get_mapped_libraries() for library_name in mapped_libraries: if not project.has_library(library_name): diff --git a/vunit/sim_if/nvc.py b/vunit/sim_if/nvc.py index 91c936259..898687b6b 100644 --- a/vunit/sim_if/nvc.py +++ b/vunit/sim_if/nvc.py @@ -22,6 +22,7 @@ from . import run_command, check_executable from ._viewermixin import ViewerMixin from ..vhdl_standard import VHDL +from ..python_bridge import simulator_hooks LOGGER = logging.getLogger(__name__) @@ -154,6 +155,13 @@ def supports_vhdl_package_generics(cls): """ return True + @classmethod + def supported_foreign_language_interfaces(cls): + """ + Returns set of supported foreign interfaces + """ + return set(["VHPIDIRECT_NVC"]) + def setup_library_mapping(self, project): """ Setup library mapping @@ -306,6 +314,7 @@ def simulate( config.vhdl_assert_stop_level ) cmd += config_sim_options + cmd += simulator_hooks.nvc_run_flags(self._project) cmd += [f"--exit-severity={config.vhdl_assert_stop_level}"] if not self._ieee_warnings_global and config.sim_options.get("disable_ieee_warnings", False): diff --git a/vunit/sim_if/rivierapro.py b/vunit/sim_if/rivierapro.py index 2b29321b2..e4f334336 100644 --- a/vunit/sim_if/rivierapro.py +++ b/vunit/sim_if/rivierapro.py @@ -100,6 +100,13 @@ def supports_vhdl_call_paths(cls): """ return True + @classmethod + def supported_foreign_language_interfaces(cls): + """ + Returns set of supported foreign interfaces + """ + return set(["VHPI"]) + @classmethod def supports_vhdl_package_generics(cls): """ diff --git a/vunit/sim_if/vsim_simulator_mixin.py b/vunit/sim_if/vsim_simulator_mixin.py index 616a1c18b..a72b10d47 100644 --- a/vunit/sim_if/vsim_simulator_mixin.py +++ b/vunit/sim_if/vsim_simulator_mixin.py @@ -12,6 +12,7 @@ import sys import os from pathlib import Path +from typing import List from ..ostools import write_file, Process from ..test.suites import get_result_file_name from ..persistent_tcl_shell import PersistentTclShell @@ -28,8 +29,15 @@ def __init__(self, prefix, persistent, sim_cfg_file_name): sim_cfg_file_name = str(Path(sim_cfg_file_name).resolve()) self._sim_cfg_file_name = sim_cfg_file_name + # Flags of the vsim processes VUnit starts itself, as opposed to the vsim commands + # of the generated TCL. The list is shared with the persistent shell below and + # filled in by add_simulator_specific(), since what goes into it depends on the + # project, which is not known when the interface is created. + self._vsim_process_flags: List[str] = [] + prefix = self._prefix # Avoid circular dependency inhibiting process destruction env = self.get_env() + process_flags = self._vsim_process_flags def create_process(ident): return Process( @@ -40,7 +48,8 @@ def create_process(ident): str(Path(sim_cfg_file_name).parent / f"transcript{ident}"), "-do", str((Path(__file__).parent / "tcl_read_eval_loop.tcl").resolve()), - ], + ] + + process_flags, cwd=str(Path(sim_cfg_file_name).parent), env=env, ) @@ -327,7 +336,7 @@ def _run_batch_file(self, batch_file_name, gui=False, gui_option="-gui", extra_a str(Path(batch_file_name).parent / "transcript"), "-do", f'source "{fix_path(batch_file_name)!s}"', - ] + ] + self._vsim_process_flags if extra_args: args += extra_args diff --git a/vunit/ui/__init__.py b/vunit/ui/__init__.py index c0b87eaea..cb33ec5b1 100644 --- a/vunit/ui/__init__.py +++ b/vunit/ui/__init__.py @@ -1319,6 +1319,25 @@ def add_array_util(self): """ self._builtins.add("array_util") + def add_python(self): + """ + Add the Python package, see :ref:`python_bridge`. + + Lets VHDL testbenches execute Python code and call Python functions through + ``context vunit_lib.python_context``. Requires :meth:`add_vhdl_builtins` to have been + called, VHDL-2008 or later, and a simulator with a supported foreign language + interface (NVC, GHDL and Questa/ModelSim use the VUnit Python bridge; + Riviera-PRO/Active-HDL use a VHPI application). + + :example: + + .. code-block:: python + + prj.add_vhdl_builtins() + prj.add_python() + """ + self._builtins.add("python") + def add_random(self): """ Add random diff --git a/vunit/vhdl/python/run.py b/vunit/vhdl/python/run.py new file mode 100644 index 000000000..8c9e136f9 --- /dev/null +++ b/vunit/vhdl/python/run.py @@ -0,0 +1,94 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Run script of the VHDL Python package. + +It is imported by tb_python_pkg through import_run_script, which is why +everything but remote_test is guarded by a __main__ check. +""" + +from pathlib import Path +from vunit import VUnit, VUnitCLI + +ROOT = Path(__file__).parent + +# The VHPI application reports Python errors fatally rather than as failures +# on python_logger, so the negative tests of tb_python_pkg cannot mock the +# logger there and fail by design. The Python bridge (NVC, GHDL, Questa) makes +# them observable from VHDL and there the whole testbench passes. +EXPECTED_FAILURES_VHPI = [ + "lib.tb_python_pkg.Test eval of integer with overflow from Python to C", + "lib.tb_python_pkg.Test eval of integer with underflow from Python to C", + "lib.tb_python_pkg.Test eval of integer with overflow from C to VHDL", + "lib.tb_python_pkg.Test eval of integer with underflow from C to VHDL", + "lib.tb_python_pkg.Test exceptions in exec", + "lib.tb_python_pkg.Test exceptions in eval", + "lib.tb_python_pkg.Test eval with type error", + "lib.tb_python_pkg.Test raising exception", + # The VHPI application rejects real values outside the float range and has + # no representation for a value that is not finite + "lib.tb_python_pkg.Test eval of real with overflow from C to VHDL", + "lib.tb_python_pkg.Test eval of real with underflow from C to VHDL", +] + +# Simulators where python_pkg is implemented by the VUnit Python bridge +BRIDGE_SIMULATORS = ["nvc", "ghdl", "modelsim"] + + +def remote_test(): + """ + Called from VHDL to verify that the run script can be imported into the + embedded Python interpreter. + """ + return 2 + + +def verify(results, expected_failures): + """ + Accept the expected failures, and nothing else. + """ + tests = results.get_report().tests + expected = [name for name in expected_failures if name in tests] + failed = sorted(name for name, test in tests.items() if test.status == "failed") + + unexpected = [name for name in failed if name not in expected] + if unexpected: + raise RuntimeError("Unexpected test failures:\n" + "\n".join(unexpected)) + + missing = [name for name in expected if name not in failed] + if missing: + raise RuntimeError("Tests expected to fail but did not:\n" + "\n".join(missing)) + + if expected: + print(f"Verified {len(expected)} expected failures: {', '.join(name.split('.')[-1] for name in expected)}") + + +def main(): + args = VUnitCLI().parse_args() + # The expected failures must not make the run script fail + args.exit_0 = True + vu = VUnit.from_args(args) + vu.add_vhdl_builtins() + vu.add_python() + + simulator_name = vu.get_simulator_name() + expected_failures = [] if simulator_name in BRIDGE_SIMULATORS else list(EXPECTED_FAILURES_VHPI) + + lib = vu.add_library("lib") + lib.add_source_file(ROOT / "test" / "tb_python_pkg.vhd") + if simulator_name in BRIDGE_SIMULATORS: + # The operations implemented by the Python bridge + lib.add_source_file(ROOT / "test" / "tb_python_pkg_bridge.vhd") + + vu.set_compile_option("rivierapro.vcom_flags", ["-dbg"]) + vu.set_sim_option("rivierapro.vsim_flags", ["-interceptcoutput"]) + + vu.main(post_run=lambda results: verify(results, expected_failures)) + + +if __name__ == "__main__": + main() diff --git a/vunit/vhdl/python/src/python_bridge_pkg.vhd.in b/vunit/vhdl/python/src/python_bridge_pkg.vhd.in new file mode 100644 index 000000000..d25e4b0de --- /dev/null +++ b/vunit/vhdl/python/src/python_bridge_pkg.vhd.in @@ -0,0 +1,196 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com +-- +-- PRIVATE foreign subprograms of the VUnit Python bridge (vunit/python_bridge/native). +-- +-- This file is a template. VUnit substitutes every {foreign:} +-- placeholder with the foreign attribute string of the selected simulator -- +-- "VHPIDIRECT " for NVC and GHDL, "fli_ +-- " for the FLI of Questa/ModelSim -- and compiles the result into +-- vunit_lib when Python support is enabled with add_python(). The public API +-- is python_pkg/python_ffi_pkg, which is implemented on top of this package. +-- +-- Only scalars and constrained arrays cross the foreign interface since their +-- ABI is identical for NVC and GHDL on all platforms and is translated to the +-- FLI parameter passing conventions by native/fli.c. + +package python_bridge_pkg is + constant chunk_length : positive := 1024; + subtype string_chunk_t is string(1 to chunk_length); + subtype integer_chunk_t is integer_vector(0 to chunk_length - 1); + subtype real_chunk_t is real_vector(0 to chunk_length - 1); + + -- Start the interpreter. Idempotent. + impure function vpy_setup return integer; + attribute foreign of vpy_setup : function is "{foreign:vpy_setup}"; + + -- Flush Python's streams and drop the last result and the staged values. + -- The interpreter is not finalized: later operations keep working. + impure function vpy_cleanup return integer; + attribute foreign of vpy_cleanup : function is "{foreign:vpy_cleanup}"; + + impure function vpy_buffer_clear return integer; + attribute foreign of vpy_buffer_clear : function is "{foreign:vpy_buffer_clear}"; + + impure function vpy_buffer_append(chunk : string_chunk_t; length : integer) return integer; + attribute foreign of vpy_buffer_append : function is "{foreign:vpy_buffer_append}"; + + -- Start an operation in the session named by the buffer ("default" is __main__). + impure function vpy_begin return integer; + attribute foreign of vpy_begin : function is "{foreign:vpy_begin}"; + + -- Execute the buffer as Python source (is_file = 0) or as a file name (is_file = 1). + impure function vpy_execute(is_file : integer) return integer; + attribute foreign of vpy_execute : function is "{foreign:vpy_execute}"; + + -- Evaluate the buffer as a Python expression and convert the value to the + -- VHDL type given by kind. width is the length of a std_logic_vector, + -- signed or unsigned result, -1 when it is not known. + impure function vpy_eval(kind, width : integer) return integer; + attribute foreign of vpy_eval : function is "{foreign:vpy_eval}"; + + impure function vpy_push_array(length, width, height, depth, bit_width, is_signed : integer) return integer; + attribute foreign of vpy_push_array : function is "{foreign:vpy_push_array}"; + + impure function vpy_array_write(chunk : integer_chunk_t; length : integer) return integer; + attribute foreign of vpy_array_write : function is "{foreign:vpy_array_write}"; + + -- Stage the pushed array and return its id (>= 1), -1 on error. Python + -- source code refers to the value as __vunit__.staged(). + impure function vpy_stage return integer; + attribute foreign of vpy_stage : function is "{foreign:vpy_stage}"; + + impure function vpy_result_integer return integer; + attribute foreign of vpy_result_integer : function is "{foreign:vpy_result_integer}"; + + impure function vpy_result_real return real; + attribute foreign of vpy_result_real : function is "{foreign:vpy_result_real}"; + + impure function vpy_result_meta(idx : integer) return integer; + attribute foreign of vpy_result_meta : function is "{foreign:vpy_result_meta}"; + + impure function vpy_error_length return integer; + attribute foreign of vpy_error_length : function is "{foreign:vpy_error_length}"; + + procedure vpy_result_read_string(chunk : out string_chunk_t; offset, length : integer); + attribute foreign of vpy_result_read_string : procedure is "{foreign:vpy_result_read_string}"; + + procedure vpy_result_read_integers(chunk : out integer_chunk_t; offset, length : integer); + attribute foreign of vpy_result_read_integers : procedure is "{foreign:vpy_result_read_integers}"; + + procedure vpy_result_read_reals(chunk : out real_chunk_t; offset, length : integer); + attribute foreign of vpy_result_read_reals : procedure is "{foreign:vpy_result_read_reals}"; + + procedure vpy_error_read(chunk : out string_chunk_t; offset, length : integer); + attribute foreign of vpy_error_read : procedure is "{foreign:vpy_error_read}"; +end package; + +package body python_bridge_pkg is + -- The bodies are replaced by the foreign implementations and never executed. + impure function vpy_setup return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_setup is not bound" severity failure; + return 1; + end; + + impure function vpy_cleanup return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_cleanup is not bound" severity failure; + return 1; + end; + + impure function vpy_buffer_clear return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_buffer_clear is not bound" severity failure; + return 1; + end; + + impure function vpy_buffer_append(chunk : string_chunk_t; length : integer) return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_buffer_append is not bound" severity failure; + return 1; + end; + + impure function vpy_begin return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_begin is not bound" severity failure; + return 1; + end; + + impure function vpy_execute(is_file : integer) return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_execute is not bound" severity failure; + return 1; + end; + + impure function vpy_eval(kind, width : integer) return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_eval is not bound" severity failure; + return 1; + end; + + impure function vpy_push_array(length, width, height, depth, bit_width, is_signed : integer) return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_push_array is not bound" severity failure; + return 1; + end; + + impure function vpy_array_write(chunk : integer_chunk_t; length : integer) return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_array_write is not bound" severity failure; + return 1; + end; + + impure function vpy_stage return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_stage is not bound" severity failure; + return -1; + end; + + impure function vpy_result_integer return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_result_integer is not bound" severity failure; + return 1; + end; + + impure function vpy_result_real return real is + begin + report "VUnit Python bridge: foreign subprogram vpy_result_real is not bound" severity failure; + return 0.0; + end; + + impure function vpy_result_meta(idx : integer) return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_result_meta is not bound" severity failure; + return 1; + end; + + impure function vpy_error_length return integer is + begin + report "VUnit Python bridge: foreign subprogram vpy_error_length is not bound" severity failure; + return 1; + end; + + procedure vpy_result_read_string(chunk : out string_chunk_t; offset, length : integer) is + begin + report "VUnit Python bridge: foreign subprogram vpy_result_read_string is not bound" severity failure; + end; + + procedure vpy_result_read_integers(chunk : out integer_chunk_t; offset, length : integer) is + begin + report "VUnit Python bridge: foreign subprogram vpy_result_read_integers is not bound" severity failure; + end; + + procedure vpy_result_read_reals(chunk : out real_chunk_t; offset, length : integer) is + begin + report "VUnit Python bridge: foreign subprogram vpy_result_read_reals is not bound" severity failure; + end; + + procedure vpy_error_read(chunk : out string_chunk_t; offset, length : integer) is + begin + report "VUnit Python bridge: foreign subprogram vpy_error_read is not bound" severity failure; + end; +end package body; diff --git a/vunit/vhdl/python/src/python_context.vhd b/vunit/vhdl/python/src/python_context.vhd new file mode 100644 index 000000000..9e786fcd1 --- /dev/null +++ b/vunit/vhdl/python/src/python_context.vhd @@ -0,0 +1,13 @@ +-- This package provides a dictionary types and operations +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +context python_context is + library vunit_lib; + use vunit_lib.python_pkg.all; + use vunit_lib.python_ffi_pkg.all; +end context; diff --git a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd new file mode 100644 index 000000000..8cc83dd77 --- /dev/null +++ b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd @@ -0,0 +1,432 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com +-- +-- The foreign language interface of the Python package for NVC and GHDL, +-- implemented with the VUnit Python bridge (vunit/python_bridge). It provides +-- the same subprograms as the FLI and VHPI variants but over the private +-- python_bridge_pkg, which VUnit generates when Python support is enabled with +-- add_python(). +-- +-- Deviations from the other variants: +-- * The subprograms are impure since they call impure foreign subprograms. +-- * Errors are reported as failures on python_logger rather than aborting +-- the simulation directly, which makes them observable from VHDL. +-- * Sessions other than the default one are supported. +-- +-- The p_ prefixed declarations are the private primitives the bridge +-- operations of python_pkg are built on. They are not part of the API. The FLI and VHPI +-- variants of this package declare the same primitives and implement them by +-- reporting a failure. + +use work.integer_array_pkg.all; +use work.logger_pkg.all; +use work.python_bridge_pkg.all; + +package python_ffi_pkg is + -- Logger used to report Python errors, for example exceptions with their traceback + constant python_logger : logger_t := get_logger("vunit_lib:python"); + + -- A session is a named Python namespace. Sessions are created on first use + -- and are isolated from each other, for example + -- + -- 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; + constant default_session : python_session_t := "default"; + + -- Start the embedded Python interpreter eagerly. Optional and idempotent: + -- without it the interpreter starts on first use. + procedure python_setup; + + -- Release the values staged for integer_array_t arguments. Optional: they + -- are released when the simulator process ends. The interpreter is not + -- finalized, so later operations keep working. + procedure python_cleanup; + + -- Execute Python source code in the namespace of a session + procedure exec(code : string; session : python_session_t := default_session); + + -- Evaluate a Python expression and convert its value to a VHDL type + impure function eval_integer(expr : string; session : python_session_t := default_session) return integer; + alias eval is eval_integer[string, python_session_t return integer]; + + impure function eval_real(expr : string; session : python_session_t := default_session) return real; + alias eval is eval_real[string, python_session_t return real]; + + impure function eval_integer_vector( + expr : string; session : python_session_t := default_session + ) return integer_vector; + alias eval is eval_integer_vector[string, python_session_t return integer_vector]; + + impure function eval_real_vector( + expr : string; session : python_session_t := default_session + ) return real_vector; + alias eval is eval_real_vector[string, python_session_t return real_vector]; + + impure function eval_string(expr : string; session : python_session_t := default_session) return string; + alias eval is eval_string[string, python_session_t return string]; + + ----------------------------------------------------------------------------- + -- Private, the primitives the bridge operations of python_pkg are built on + ----------------------------------------------------------------------------- + -- Result kinds, must match vunit/python_bridge/runtime.py + constant p_kind_integer : integer := 0; + constant p_kind_real : integer := 1; + constant p_kind_boolean : integer := 2; + constant p_kind_string : integer := 3; + constant p_kind_std_logic : integer := 4; + constant p_kind_std_logic_vector : integer := 5; + constant p_kind_signed : integer := 6; + constant p_kind_unsigned : integer := 7; + constant p_kind_integer_array : integer := 8; + constant p_kind_integer_vector : integer := 9; + constant p_kind_real_vector : integer := 10; + + -- Names of the operations, used in the error messages + function p_exec_operation(session : python_session_t := default_session) return string; + function p_exec_file_operation( + file_name : string; session : python_session_t := default_session + ) return string; + function p_eval_operation(expr : string; session : python_session_t := default_session) return string; + + -- The error of the last failed bridge operation, typically a Python traceback + impure function p_error_text return string; + + -- Report a failed operation (status /= 0) with its error. True if it succeeded. + impure function p_succeeded(status : integer; operation : string) return boolean; + + -- Transfer a string to the bridge buffer. Returns the bridge status. + impure function p_send(text : string) return integer; + + -- Start an operation in a session + impure function p_begin(session : python_session_t; operation : string) return boolean; + + -- Execute text as Python source code (is_file = 0) or as a Python file name (is_file = 1) + impure function p_exec( + text : string; + is_file : integer; + operation : string; + session : python_session_t := default_session + ) return boolean; + + -- Execute the Python file with the given name + impure function p_exec_file( + file_name : string; session : python_session_t := default_session + ) return boolean; + + -- Evaluate expr and convert the value to the VHDL type given by kind. width + -- is the length of a std_logic_vector, signed or unsigned result, -1 when + -- it is not known. + impure function p_eval( + expr : string; + kind : integer; + width : integer; + operation : string; + session : python_session_t := default_session + ) return boolean; + + -- The result of the last evaluation + impure function p_result_integer return integer; + impure function p_result_string return string; + impure function p_result_integer_vector return integer_vector; + impure function p_result_real_vector return real_vector; + impure function p_result_integer_array return integer_array_t; + + -- Transfer an integer_array_t to Python and return the id it is staged + -- under, -1 on failure. Python source code refers to the value as + -- __vunit__.staged(). + impure function p_stage_array(arr : integer_array_t; operation : string) return integer; +end package; + +package body python_ffi_pkg is + ----------------------------------------------------------------------------- + -- Operation names + ----------------------------------------------------------------------------- + function p_exec_operation(session : python_session_t := default_session) return string is + begin + if session = default_session then + return "exec"; + end if; + return "exec(session => """ & string(session) & """)"; + end; + + function p_exec_file_operation( + file_name : string; session : python_session_t := default_session + ) return string is + begin + if session = default_session then + return "exec_file(""" & file_name & """)"; + end if; + return "exec_file(""" & file_name & """, session => """ & string(session) & """)"; + end; + + function p_eval_operation(expr : string; session : python_session_t := default_session) return string is + begin + if session = default_session then + return "eval(""" & expr & """)"; + end if; + return "eval(""" & expr & """, session => """ & string(session) & """)"; + end; + + ----------------------------------------------------------------------------- + -- Transfers through the bridge + ----------------------------------------------------------------------------- + impure function p_error_text return string is + constant len : natural := vpy_error_length; + variable result : string(1 to len); + variable chunk : string_chunk_t; + variable offset, n : natural := 0; + begin + while offset < len loop + n := minimum(chunk_length, len - offset); + vpy_error_read(chunk, offset, n); + result(offset + 1 to offset + n) := chunk(1 to n); + offset := offset + n; + end loop; + return result; + end; + + impure function p_succeeded(status : integer; operation : string) return boolean is + begin + if status = 0 then + return true; + end if; + failure(python_logger, operation & " failed:" & LF & p_error_text); + return false; + end; + + impure function p_send(text : string) return integer is + alias normalized : string(1 to text'length) is text; + variable chunk : string_chunk_t; + variable offset, n : natural := 0; + variable status : integer; + begin + status := vpy_buffer_clear; + while offset < text'length and status = 0 loop + n := minimum(chunk_length, text'length - offset); + chunk(1 to n) := normalized(offset + 1 to offset + n); + status := vpy_buffer_append(chunk, n); + offset := offset + n; + end loop; + return status; + end; + + impure function p_begin(session : python_session_t; operation : string) return boolean is + begin + return p_succeeded(p_send(string(session)), operation) and p_succeeded(vpy_begin, operation); + end; + + impure function p_exec( + text : string; + is_file : integer; + operation : string; + session : python_session_t := default_session + ) return boolean is + begin + return p_begin(session, operation) + and p_succeeded(p_send(text), operation) + and p_succeeded(vpy_execute(is_file), operation); + end; + + impure function p_exec_file( + file_name : string; session : python_session_t := default_session + ) return boolean is + begin + return p_exec(file_name, 1, p_exec_file_operation(file_name, session), session); + end; + + impure function p_eval( + expr : string; + kind : integer; + width : integer; + operation : string; + session : python_session_t := default_session + ) return boolean is + begin + return p_begin(session, operation) + and p_succeeded(p_send(expr), operation) + and p_succeeded(vpy_eval(kind, width), operation); + end; + + ----------------------------------------------------------------------------- + -- Results + ----------------------------------------------------------------------------- + impure function p_result_integer return integer is + begin + return vpy_result_integer; + end; + + impure function p_result_string return string is + constant len : natural := vpy_result_meta(0); + variable result : string(1 to len); + variable chunk : string_chunk_t; + variable offset, n : natural := 0; + begin + while offset < len loop + n := minimum(chunk_length, len - offset); + vpy_result_read_string(chunk, offset, n); + result(offset + 1 to offset + n) := chunk(1 to n); + offset := offset + n; + end loop; + return result; + end; + + impure function p_result_integer_vector return integer_vector is + constant len : natural := vpy_result_meta(0); + variable result : integer_vector(0 to len - 1); + variable chunk : integer_chunk_t; + variable offset, n : natural := 0; + begin + while offset < len loop + n := minimum(chunk_length, len - offset); + vpy_result_read_integers(chunk, offset, n); + result(offset to offset + n - 1) := chunk(0 to n - 1); + offset := offset + n; + end loop; + return result; + end; + + impure function p_result_real_vector return real_vector is + constant len : natural := vpy_result_meta(0); + variable result : real_vector(0 to len - 1); + variable chunk : real_chunk_t; + variable offset, n : natural := 0; + begin + while offset < len loop + n := minimum(chunk_length, len - offset); + vpy_result_read_reals(chunk, offset, n); + result(offset to offset + n - 1) := chunk(0 to n - 1); + offset := offset + n; + end loop; + return result; + end; + + impure function p_result_integer_array return integer_array_t is + constant len : natural := vpy_result_meta(0); + variable result : integer_array_t := new_3d( + width => vpy_result_meta(1), + height => vpy_result_meta(2), + depth => vpy_result_meta(3), + bit_width => vpy_result_meta(4), + is_signed => vpy_result_meta(5) /= 0 + ); + variable chunk : integer_chunk_t; + variable offset, n : natural := 0; + begin + while offset < len loop + n := minimum(chunk_length, len - offset); + vpy_result_read_integers(chunk, offset, n); + for idx in 0 to n - 1 loop + set(result, offset + idx, chunk(idx)); + end loop; + offset := offset + n; + end loop; + return result; + end; + + ----------------------------------------------------------------------------- + -- Values transferred to Python + ----------------------------------------------------------------------------- + impure function p_stage_array(arr : integer_array_t; operation : string) return integer is + constant len : natural := length(arr); + variable chunk : integer_chunk_t; + variable offset, n : natural := 0; + variable staged_id : integer; + begin + if not p_succeeded( + vpy_push_array(len, width(arr), height(arr), depth(arr), bit_width(arr), boolean'pos(is_signed(arr))), + operation + ) then + return -1; + end if; + + while offset < len loop + n := minimum(chunk_length, len - offset); + for idx in 0 to n - 1 loop + chunk(idx) := get(arr, offset + idx); + end loop; + if not p_succeeded(vpy_array_write(chunk, n), operation) then + return -1; + end if; + offset := offset + n; + end loop; + + staged_id := vpy_stage; + if staged_id < 1 then + failure(python_logger, operation & " failed:" & LF & p_error_text); + return -1; + end if; + return staged_id; + end; + + ----------------------------------------------------------------------------- + -- The API + ----------------------------------------------------------------------------- + procedure python_setup is + variable ok : boolean; + begin + ok := p_succeeded(vpy_setup, "python_setup"); + end; + + procedure python_cleanup is + variable ok : boolean; + begin + ok := p_succeeded(vpy_cleanup, "python_cleanup"); + end; + + procedure exec(code : string; session : python_session_t := default_session) is + variable ok : boolean; + begin + ok := p_exec(code, 0, p_exec_operation(session), session); + end; + + impure function eval_integer(expr : string; session : python_session_t := default_session) return integer is + begin + if p_eval(expr, p_kind_integer, -1, p_eval_operation(expr, session), session) then + return vpy_result_integer; + end if; + return integer'low; + end; + + impure function eval_real(expr : string; session : python_session_t := default_session) return real is + begin + if p_eval(expr, p_kind_real, -1, p_eval_operation(expr, session), session) then + return vpy_result_real; + end if; + return 0.0; + end; + + impure function eval_integer_vector( + expr : string; session : python_session_t := default_session + ) return integer_vector is + constant no_integers : integer_vector(0 downto 1) := (others => 0); + begin + if p_eval(expr, p_kind_integer_vector, -1, p_eval_operation(expr, session), session) then + return p_result_integer_vector; + end if; + return no_integers; + end; + + impure function eval_real_vector( + expr : string; session : python_session_t := default_session + ) return real_vector is + constant no_reals : real_vector(0 downto 1) := (others => 0.0); + begin + if p_eval(expr, p_kind_real_vector, -1, p_eval_operation(expr, session), session) then + return p_result_real_vector; + end if; + return no_reals; + end; + + impure function eval_string(expr : string; session : python_session_t := default_session) return string is + begin + if p_eval(expr, p_kind_string, -1, p_eval_operation(expr, session), session) then + return p_result_string; + end if; + return ""; + end; +end package body; diff --git a/vunit/vhdl/python/src/python_pkg.c b/vunit/vhdl/python/src/python_pkg.c new file mode 100644 index 000000000..85faa6794 --- /dev/null +++ b/vunit/vhdl/python/src/python_pkg.c @@ -0,0 +1,125 @@ +// This package provides a dictionary types and operations +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2014-2023, Lars Asplund lars.anders.asplund@gmail.com + +#include "python_pkg.h" + +#include + +static py_error_handler_callback py_error_handler = NULL; +static ffi_error_handler_callback ffi_error_handler = NULL; + +void register_py_error_handler(py_error_handler_callback callback) { + py_error_handler = callback; +} + +void register_ffi_error_handler(ffi_error_handler_callback callback) { + ffi_error_handler = callback; +} + +char* get_string(PyObject* pyobj) { + PyObject* str = PyObject_Str(pyobj); + if (str == NULL) { + return NULL; + } + + PyObject* str_utf_8 = PyUnicode_AsEncodedString(str, "utf-8", NULL); + Py_DECREF(str); + if (str_utf_8 == NULL) { + return NULL; + } + + // A copy, since the bytes object is released here. The string is only used + // on the error path, so the copy is never freed. + char* result = strdup(PyBytes_AS_STRING(str_utf_8)); + Py_DECREF(str_utf_8); + + return result; +} + +void check_conversion_error(const char* expr) { + PyObject* exc = PyErr_Occurred(); + if (exc != NULL) { + Py_DECREF(exc); + py_error_handler("parsing evaluation result of", expr, NULL, true); + } +} + +void handle_type_check_error(PyObject* pyobj, const char* context, + const char* expr) { + PyTypeObject* type = pyobj->ob_type; + if (type == NULL) { + py_error_handler(context, expr, "Expression evaluates to an unknown type.", + true); + } + const char* type_name_str = type->tp_name; + Py_DECREF(type); + + if (type_name_str == NULL) { + py_error_handler(context, expr, "Expression evaluates to an unknown type.", + true); + } + + char error_message[100] = "Expression evaluates to "; + strncat(error_message, type_name_str, 75); + py_error_handler(context, expr, error_message, true); +} + +PyObject* eval(const char* expr) { + PyObject* pyobj = PyRun_String(expr, Py_eval_input, globals, locals); + if (pyobj == NULL) { + py_error_handler("evaluating", expr, NULL, true); + } + + return pyobj; +} + +int get_integer(PyObject* pyobj, const char* expr, bool dec_ref_count) { + // Check that the Python object has the correct type + if (!PyLong_Check(pyobj)) { + handle_type_check_error(pyobj, "evaluating to integer", expr); + } + + // Convert from Python-typed to C-typed value and check for any errors such as + // overflow/underflow + long value = PyLong_AsLong(pyobj); + if (dec_ref_count) { + Py_DECREF(pyobj); + } + check_conversion_error(expr); + + // TODO: Assume that the simulator is limited to 32-bits for now + if ((value > pow(2, 31) - 1) || (value < -pow(2, 31))) { + py_error_handler("parsing evaluation result of", expr, + "Result out of VHDL integer range.", true); + } + + return (int)value; +} + +double get_real(PyObject* pyobj, const char* expr, bool dec_ref_count) { + // Check that the Python object has the correct type + if (!PyFloat_Check(pyobj)) { + handle_type_check_error(pyobj, "evaluating to real", expr); + } + + // Convert from Python-typed to C-typed value and check for any errors such as + // overflow/underflow + double value = PyFloat_AsDouble(pyobj); + if (dec_ref_count) { + Py_DECREF(pyobj); + } + check_conversion_error(expr); + + // TODO: Assume that the simulator is limited to 32-bits for now + if ((value > 3.4028234664e38) || (value < -3.4028234664e38)) { + py_error_handler("parsing evaluation result of", expr, + "Result out of VHDL real range.", true); + } + + return value; +} diff --git a/vunit/vhdl/python/src/python_pkg.h b/vunit/vhdl/python/src/python_pkg.h new file mode 100644 index 000000000..950a8d555 --- /dev/null +++ b/vunit/vhdl/python/src/python_pkg.h @@ -0,0 +1,31 @@ +// This package provides a dictionary types and operations +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2014-2023, Lars Asplund lars.anders.asplund@gmail.com + +#include +#include + +#define PY_SSIZE_T_CLEAN +#include "Python.h" + +extern PyObject* globals; +extern PyObject* locals; + +typedef void (*py_error_handler_callback)(const char*, const char*, const char*, + bool); +void register_py_error_handler(py_error_handler_callback callback); + +typedef void (*ffi_error_handler_callback)(const char*, bool); +void register_ffi_error_handler(ffi_error_handler_callback callback); + +char* get_string(PyObject* pyobj); +void check_conversion_error(const char* expr); +void handle_type_check_error(PyObject* pyobj, const char* context, + const char* expr); +PyObject* eval(const char* expr); +int get_integer(PyObject* pyobj, const char* expr, bool dec_ref_count); +double get_real(PyObject* pyobj, const char* expr, bool dec_ref_count); diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd new file mode 100644 index 000000000..a28a089a3 --- /dev/null +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -0,0 +1,1064 @@ +-- This package provides a dictionary types and operations +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +use work.python_ffi_pkg.all; +use work.path.all; +use work.run_pkg.all; +use work.runner_pkg.all; +use work.integer_vector_ptr_pkg.all; +use work.string_ops.all; + +use std.textio.all; + +------------------------------------------------------------------------------ +-- This file is generated from tools/python_pkg.vhd.in by +-- vunit/vhdl/python/tools/generate_python_pkg.py. Do not edit. +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +use work.integer_array_pkg.all; +use work.logger_pkg.all; +------------------------------------------------------------------------------ + +package python_pkg is + procedure import_module_from_file( + module_path, as_module_name : string; session : python_session_t := default_session + ); + procedure import_run_script(module_name : string := ""; session : python_session_t := default_session); + + function to_py_list_str(vec : integer_vector) return string; + impure function to_py_list_str(vec : integer_vector_ptr_t) return string; + function to_py_list_str(vec : real_vector) return string; + + function "+"(l, r : string) return string; + + impure function eval_integer_vector_ptr( + expr : string; session : python_session_t := default_session + ) return integer_vector_ptr_t; + alias eval is eval_integer_vector_ptr[string, python_session_t return integer_vector_ptr_t]; + + function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" + ) return string; + + type arg_t is record + name : string; + value : string; + end record; + constant p_positional_arg : string := "."; + constant p_ignore_arg : string := "-"; + constant null_arg : arg_t := (name => p_ignore_arg, value => ""); + + function arg(value : integer) return arg_t; + function kwarg(kw : string; value : integer) return arg_t; + function arg(value : string) return arg_t; + function kwarg(kw : string; value : string) return arg_t; + function arg(value : integer_vector) return arg_t; + function kwarg(kw : string; value : integer_vector) return arg_t; + function arg(value : real) return arg_t; + function kwarg(kw : string; value : real) return arg_t; + function arg(value : boolean) return arg_t; + function kwarg(kw : string; value : boolean) return arg_t; + + impure function call_integer_w_arg( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer; + alias call is call_integer_w_arg[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return integer]; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ); + + impure function call_integer_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector; + alias call is call_integer_vector[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t + return integer_vector]; + + impure function call_real( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real; + alias call is call_real[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real]; + + ----------------------------------------------------------------------------- + -- More argument values of call + ----------------------------------------------------------------------------- + -- real_vector and integer_vector_ptr_t values become Python lists, a + -- std_logic value becomes True or False, and an unsigned or signed value of + -- any width becomes a Python integer written as a hexadecimal literal, which + -- is therefore not limited to the range of a VHDL integer. + -- + -- An aggregate or a literal needs a qualified expression to select the + -- overload, for example arg(real_vector'(1.0, 2.0)). A std_logic_vector is + -- passed as a string, arg(to_string(slv)), or as a number, + -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of + -- their own since an overload would make a string literal argument, + -- arg("Hello"), ambiguous. + -- + -- H and L are read as 1 and 0. Any other metavalue is an error. + function arg(value : real_vector) return arg_t; + function kwarg(kw : string; value : real_vector) return arg_t; + impure function arg(value : integer_vector_ptr_t) return arg_t; + impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t; + impure function arg(value : std_logic) return arg_t; + impure function kwarg(kw : string; value : std_logic) return arg_t; + impure function arg_unsigned(value : unsigned) return arg_t; + impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t; + impure function arg_signed(value : signed) return arg_t; + impure function kwarg_signed(kw : string; value : signed) return arg_t; + + ----------------------------------------------------------------------------- + -- Keyword argument groups + ----------------------------------------------------------------------------- + -- Keyword arguments combined with & become a single argument, so that a call + -- can pass more than 10 keyword arguments and can build its keyword + -- arguments in steps: + -- + -- call("f", arg(x), kwarg("a", 1) & kwarg("b", 2)); + -- + -- calls f(x, **dict(a=1, b=2)). null_arg is the identity of the operation + -- and only keyword arguments, or groups of them, can be combined. Python + -- keeps its own rules: a repeated keyword and a group followed by a + -- positional argument are syntax errors. + impure function "&"(l, r : arg_t) return arg_t; + + ----------------------------------------------------------------------------- + -- Operations implemented by the Python bridge (NVC, GHDL and Questa) + ----------------------------------------------------------------------------- + -- The operations below are implemented by the VUnit Python bridge and are + -- therefore only available for NVC, GHDL and Questa; the other simulators + -- report a failure when they are used. Like the other operations, every one + -- of them takes the session it is performed in as its last parameter, + -- defaulting to the default session. + + ----------------------------------------------------------------------------- + -- Argument values of call: integer arrays + ----------------------------------------------------------------------------- + -- An integer_array_t value is transferred to Python and referred to by the + -- expression, which means that it can be used in several calls. + impure function arg(value : integer_array_t) return arg_t; + impure function kwarg(kw : string; value : integer_array_t) return arg_t; + + ----------------------------------------------------------------------------- + -- Results of eval: boolean, std_logic, vectors and arrays + ----------------------------------------------------------------------------- + -- std_logic_vector and integer_array_t results are only available under + -- their explicit names, not as eval overloads, which keeps + -- check_equal(eval("17"), 17) and length(eval("[1, 2]")) unambiguous. + -- signed and unsigned results are only available in the procedure form + -- since a function cannot know the width of the result. + impure function eval_boolean( + expr : string; session : python_session_t := default_session + ) return boolean; + alias eval is eval_boolean[string, python_session_t return boolean]; + + impure function eval_std_logic( + expr : string; session : python_session_t := default_session + ) return std_logic; + alias eval is eval_std_logic[string, python_session_t return std_logic]; + + impure function eval_std_logic_vector( + expr : string; session : python_session_t := default_session + ) return std_logic_vector; + + impure function eval_integer_array( + expr : string; session : python_session_t := default_session + ) return integer_array_t; + + procedure eval_std_logic_vector( + expr : string; result : out std_logic_vector; session : python_session_t := default_session + ); + procedure eval_signed( + expr : string; result : out signed; session : python_session_t := default_session + ); + procedure eval_unsigned( + expr : string; result : out unsigned; session : python_session_t := default_session + ); + + ----------------------------------------------------------------------------- + -- Results of call: string, boolean, std_logic, vectors and arrays + ----------------------------------------------------------------------------- + impure function call_real_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real_vector; + alias call is call_real_vector[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real_vector]; + + impure function call_string( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return string; + alias call is call_string[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return string]; + + impure function call_integer_vector_ptr( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector_ptr_t; + alias call is call_integer_vector_ptr[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return integer_vector_ptr_t]; + + impure function call_boolean( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return boolean; + alias call is call_boolean[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return boolean]; + + impure function call_std_logic( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return std_logic; + alias call is call_std_logic[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return std_logic]; + + impure function call_std_logic_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return std_logic_vector; + + impure function call_integer_array( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_array_t; + alias call is call_integer_array[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return integer_array_t]; + + procedure call_std_logic_vector( + identifier : string; result : out std_logic_vector; + arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ); + procedure call_signed( + identifier : string; result : out signed; + arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ); + procedure call_unsigned( + identifier : string; result : out unsigned; + arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ); + + ----------------------------------------------------------------------------- + -- Execution of Python files + ----------------------------------------------------------------------------- + -- Execute a Python file. A relative file name is relative to the directory + -- of the run script. The file is executed with __file__ set and its own + -- directory on sys.path so that it can import its siblings. + procedure exec_file(file_name : string; session : python_session_t := default_session); +end package; + +package body python_pkg is + -- @formatter:off + procedure import_module_from_file( + module_path, as_module_name : string; session : python_session_t := default_session + ) is + constant spec_name : string := "__" & as_module_name & "_spec"; + constant code : string := + "from importlib.util import spec_from_file_location, module_from_spec" & LF & + "from pathlib import Path" & LF & + "import sys" & LF & + spec_name & " = spec_from_file_location('" & as_module_name & "', str(Path('" & module_path & "')))" & LF & + as_module_name & " = module_from_spec(" & spec_name & ")" & LF & + "sys.modules['" & as_module_name & "'] = " & as_module_name & LF & + spec_name & ".loader.exec_module(" & as_module_name & ")"; + begin + exec(code, session); + end; + -- @formatter:on + + -- Importing a run script runs its module level code, so one without an + -- if __name__ == "__main__" guard would start VUnit again + procedure p_check_run_script_guard(script_path : string; session : python_session_t) is + begin + exec( + "from pathlib import Path" & LF & + "if '__main__' not in Path('" & script_path & "').read_text():" & LF & + " raise RuntimeError('The run script " & script_path & " has no if __name__ == ""__main__"" guard, " & + "importing it would run VUnit again')", + session); + end; + + procedure import_run_script(module_name : string := ""; session : python_session_t := default_session) is + constant script_path : string := run_script_path(get_cfg(runner_state)); + variable path_items : lines_t; + variable script_name : line; + begin + p_check_run_script_guard(script_path, session); + if module_name = "" then + -- Extract the last item in the full path + path_items := split(script_path, "/"); + for idx in path_items'range loop + if idx = path_items'right then + script_name := path_items(idx); + else + deallocate(path_items(idx)); + end if; + end loop; + deallocate(path_items); + + -- Set module name to script name minus its extension + path_items := split(script_name.all, "."); + deallocate(script_name); + for idx in path_items'range loop + if idx = path_items'left then + import_module_from_file(script_path, path_items(idx).all, session); + end if; + deallocate(path_items(idx)); + end loop; + deallocate(path_items); + else + import_module_from_file(script_path, module_name, session); + end if; + end; + + function to_py_list_str(vec : integer_vector) return string is + variable l : line; + begin + swrite(l, "["); + for idx in vec'range loop + swrite(l, to_string(vec(idx))); + if idx /= vec'right then + swrite(l, ","); + end if; + end loop; + swrite(l, "]"); + + return l.all; + end; + + impure function to_py_list_str(vec : integer_vector_ptr_t) return string is + variable l : line; + begin + swrite(l, "["); + for idx in 0 to length(vec) - 1 loop + swrite(l, to_string(get(vec, idx))); + if idx /= length(vec) - 1 then + swrite(l, ","); + end if; + end loop; + swrite(l, "]"); + + return l.all; + end; + + function to_py_list_str(vec : real_vector) return string is + variable l : line; + begin + swrite(l, "["); + for idx in vec'range loop + -- Use %.16e to ensure that the string representation of the real number is precise enough to avoid loss of + -- information when double-precision is used. + swrite(l, to_string(vec(idx), "%.16e")); + if idx /= vec'right then + swrite(l, ","); + end if; + end loop; + swrite(l, "]"); + + return l.all; + end; + + function "+"(l, r : string) return string is + begin + return l & LF & r; + end; + + impure function eval_integer_vector_ptr( + expr : string; session : python_session_t := default_session + ) return integer_vector_ptr_t is + constant result_integer_vector : integer_vector := eval(expr, session); + constant len : natural := result_integer_vector'length; + constant result_integer_vector_normalized : integer_vector(0 to len - 1) := result_integer_vector; + constant result : integer_vector_ptr_t := new_integer_vector_ptr(len); + begin + for idx in 0 to len - 1 loop + set(result, idx, result_integer_vector_normalized(idx)); + end loop; + + return result; + end; + + function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" + ) return string is + -- The non-empty arguments, separated by ", " + function append(args, arg : string) return string is + begin + if arg = "" then + return args; + elsif args = "" then + return arg; + else + return args & ", " & arg; + end if; + end; + begin + return identifier & "(" & append(append(append(append(append(append(append(append(append(append( + "", arg1), arg2), arg3), arg4), arg5), arg6), arg7), arg8), arg9), arg10) & ")"; + end; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" + ) is + begin + exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)); + end; + + function arg(value : integer) return arg_t is + begin + return (p_positional_arg, to_string(value)); + end; + + function kwarg(kw : string; value : integer) return arg_t is + begin + return (kw, to_string(value)); + end; + + function arg(value : string) return arg_t is + begin + return (p_positional_arg, '"' & value & '"'); + end; + + function kwarg(kw : string; value : string) return arg_t is + begin + return (kw, '"' & value & '"'); + end; + + function arg(value : integer_vector) return arg_t is + begin + return (p_positional_arg, to_py_list_str(value)); + end; + + function kwarg(kw : string; value : integer_vector) return arg_t is + begin + return (kw, to_py_list_str(value)); + end; + + function arg(value : real) return arg_t is + begin + return (p_positional_arg, to_string(value, "%.16e")); + end; + + function kwarg(kw : string; value : real) return arg_t is + begin + return (kw, to_string(value, "%.16e")); + end; + + function arg(value : boolean) return arg_t is + begin + if value then + return (p_positional_arg, "True"); + else + return (p_positional_arg, "False"); + end if; + end; + + function kwarg(kw : string; value : boolean) return arg_t is + begin + if value then + return (kw, "True"); + else + return (kw, "False"); + end if; + end; + + impure function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t + ) return string is + + function arg_to_str(value : arg_t) return string is + begin + if value.name = p_ignore_arg then + return ""; + elsif value.name = p_positional_arg then + return value.value; + else + return value.name & "=" & value.value; + end if; + end; + + constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; + begin + return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); + end; + + impure function call_integer_w_arg( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_integer_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_real( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + ----------------------------------------------------------------------------- + -- More argument values of call + ----------------------------------------------------------------------------- + -- The Python source text of an argument value. The operation is only used to + -- report a value that cannot be converted, which gives an empty string. + function p_arg_value(value : real_vector; operation : string) return string is + begin + return to_py_list_str(value); + end; + + impure function p_arg_value(value : integer_vector_ptr_t; operation : string) return string is + begin + return to_py_list_str(value); + end; + + impure function p_arg_value(value : std_logic; operation : string) return string is + begin + if to_x01(value) = '1' then + return "True"; + elsif to_x01(value) = '0' then + return "False"; + end if; + failure( + python_logger, + operation & " cannot convert " & std_logic'image(value) & "; expected '0', '1', 'L' or 'H'" + ); + return ""; + end; + + -- True when the vector has an element other than 0, 1, L and H + function p_has_metavalue(value : std_logic_vector) return boolean is + begin + for idx in value'range loop + if to_x01(value(idx)) = 'X' then + return true; + end if; + end loop; + return false; + end; + + -- The Python hexadecimal literal of the magnitude given by the bits, for + -- example 0x1F or -0x80. A null range gives 0x0. + function p_to_hex_literal(bits : std_logic_vector; negative : boolean) return string is + constant hex_digits : string(1 to 16) := "0123456789ABCDEF"; + constant padding : natural := (4 - bits'length mod 4) mod 4; + variable padded : std_logic_vector(bits'length + padding - 1 downto 0) := (others => '0'); + variable result : line; + variable digit : natural; + variable leading : boolean := true; + begin + padded(bits'length - 1 downto 0) := bits; + swrite(result, "0x"); + for nibble in padded'length / 4 - 1 downto 0 loop + digit := 0; + for idx in 3 downto 0 loop + digit := 2 * digit; + if padded(4 * nibble + idx) = '1' then + digit := digit + 1; + end if; + end loop; + if digit /= 0 then + leading := false; + end if; + if not leading then + swrite(result, string'(1 => hex_digits(digit + 1))); + end if; + end loop; + if leading then + swrite(result, "0"); + end if; + + if negative then + return "-" & result.all; + end if; + return result.all; + end; + + -- The error reported for a vector value that cannot be converted + function p_metavalue_error(value : std_logic_vector; operation : string) return string is + begin + return operation & " cannot convert """ & to_string(value) & """; the value has metavalues"; + end; + + impure function p_arg_value(value : unsigned; operation : string) return string is + begin + if p_has_metavalue(std_logic_vector(value)) then + failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + return ""; + end if; + return p_to_hex_literal(to_x01(std_logic_vector(value)), false); + end; + + impure function p_arg_value(value : signed; operation : string) return string is + variable folded : signed(value'length - 1 downto 0); + variable magnitude : signed(value'length downto 0); + begin + if p_has_metavalue(std_logic_vector(value)) then + failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + return ""; + end if; + if value'length = 0 then + return "0x0"; + end if; + + folded := signed(to_x01(std_logic_vector(value))); + if folded(folded'left) = '0' then + return p_to_hex_literal(std_logic_vector(folded), false); + end if; + -- Resizing before negating keeps the magnitude of signed'low representable + magnitude := -resize(folded, value'length + 1); + return p_to_hex_literal(std_logic_vector(magnitude), true); + end; + + function arg(value : real_vector) return arg_t is + constant text : string := p_arg_value(value, "arg"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + function kwarg(kw : string; value : real_vector) return arg_t is + constant text : string := p_arg_value(value, "kwarg"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + impure function arg(value : integer_vector_ptr_t) return arg_t is + constant text : string := p_arg_value(value, "arg"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t is + constant text : string := p_arg_value(value, "kwarg"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + impure function arg(value : std_logic) return arg_t is + constant text : string := p_arg_value(value, "arg"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + impure function kwarg(kw : string; value : std_logic) return arg_t is + constant text : string := p_arg_value(value, "kwarg"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + impure function arg_unsigned(value : unsigned) return arg_t is + constant text : string := p_arg_value(value, "arg_unsigned"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t is + constant text : string := p_arg_value(value, "kwarg_unsigned"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + impure function arg_signed(value : signed) return arg_t is + constant text : string := p_arg_value(value, "arg_signed"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + impure function kwarg_signed(kw : string; value : signed) return arg_t is + constant text : string := p_arg_value(value, "kwarg_signed"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + ----------------------------------------------------------------------------- + -- Keyword argument groups + ----------------------------------------------------------------------------- + -- A group of keyword arguments is a positional argument whose Python source + -- text is **dict(a=1, b=2), spliced into the call like any other positional + -- argument. The text of an arg(string) value is quoted and the text of the + -- other positional values is a number, a list or a name, so none of them can + -- be mistaken for a group. + constant p_group_prefix : string := "**dict("; + + function p_is_group(value : arg_t) return boolean is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name /= p_positional_arg or text'length < p_group_prefix'length then + return false; + end if; + return text(1 to p_group_prefix'length) = p_group_prefix; + end; + + -- The keyword arguments of an operand as they are written within **dict(...) + function p_group_items(value : arg_t) return string is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name = p_positional_arg then + -- A group: drop the **dict( prefix and the trailing ) + return text(p_group_prefix'length + 1 to text'length - 1); + end if; + return value.name & "=" & value.value; + end; + + impure function "&"(l, r : arg_t) return arg_t is + begin + if l.name = p_ignore_arg then + return r; + elsif r.name = p_ignore_arg then + return l; + elsif (l.name = p_positional_arg and not p_is_group(l)) or + (r.name = p_positional_arg and not p_is_group(r)) then + failure(python_logger, "Only keyword arguments can be combined with & into a keyword argument group"); + return null_arg; + end if; + + return (p_positional_arg, p_group_prefix & p_group_items(l) & ", " & p_group_items(r) & ")"); + end; + + ----------------------------------------------------------------------------- + -- Operations implemented by the Python bridge (NVC, GHDL and Questa) + ----------------------------------------------------------------------------- + -- Python represents std_logic values by these characters + constant p_std_logic_characters : string(1 to 9) := "UX01ZWLH-"; + + function p_to_std_logic(value : character) return std_logic is + begin + for idx in p_std_logic_characters'range loop + if p_std_logic_characters(idx) = value then + return std_logic'val(idx - 1); + end if; + end loop; + -- The bridge only returns the characters above + return 'X'; + end; + + -- The elements of the characters, the first character becoming the leftmost element + function p_to_std_logic_vector(value : string) return std_logic_vector is + alias normalized : string(1 to value'length) is value; + variable result : std_logic_vector(value'length - 1 downto 0); + begin + for idx in normalized'range loop + result(value'length - idx) := p_to_std_logic(normalized(idx)); + end loop; + return result; + end; + + ----------------------------------------------------------------------------- + -- The Python expression calling identifier with the given arguments + ----------------------------------------------------------------------------- + function p_to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg + ) return string is + variable result : line; + variable first : boolean := true; + + procedure append(value : arg_t) is + begin + if value.name = p_ignore_arg then + return; + end if; + if not first then + swrite(result, ", "); + end if; + first := false; + if value.name = p_positional_arg then + swrite(result, value.value); + else + swrite(result, value.name & "=" & value.value); + end if; + end; + begin + swrite(result, identifier & "("); + append(arg1); + append(arg2); + append(arg3); + append(arg4); + append(arg5); + append(arg6); + append(arg7); + append(arg8); + append(arg9); + append(arg10); + swrite(result, ")"); + + return result.all; + end; + + ----------------------------------------------------------------------------- + -- arg and kwarg + ----------------------------------------------------------------------------- + impure function p_arg_value(value : integer_array_t; operation : string) return string is + constant staged_id : integer := p_stage_array(value, operation); + begin + if staged_id < 1 then + return ""; + end if; + return "__vunit__.staged(" & integer'image(staged_id) & ")"; + end; + + impure function arg(value : integer_array_t) return arg_t is + constant text : string := p_arg_value(value, "arg"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + impure function kwarg(kw : string; value : integer_array_t) return arg_t is + constant text : string := p_arg_value(value, "kwarg"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + ----------------------------------------------------------------------------- + -- exec + ----------------------------------------------------------------------------- + procedure exec_file(file_name : string; session : python_session_t := default_session) is + variable ok : boolean; + begin + ok := p_exec_file(file_name, session); + end; + + ----------------------------------------------------------------------------- + -- eval + ----------------------------------------------------------------------------- + impure function eval_boolean( + expr : string; session : python_session_t := default_session + ) return boolean is + begin + if p_eval(expr, p_kind_boolean, -1, p_eval_operation(expr, session), session) then + return p_result_integer /= 0; + end if; + return false; + end; + + impure function eval_std_logic( + expr : string; session : python_session_t := default_session + ) return std_logic is + begin + if p_eval(expr, p_kind_std_logic, -1, p_eval_operation(expr, session), session) then + return p_to_std_logic(p_result_string(1)); + end if; + return 'U'; + end; + + impure function eval_std_logic_vector( + expr : string; session : python_session_t := default_session + ) return std_logic_vector is + begin + if p_eval(expr, p_kind_std_logic_vector, -1, p_eval_operation(expr, session), session) then + return p_to_std_logic_vector(p_result_string); + end if; + return ""; + end; + + impure function eval_integer_array( + expr : string; session : python_session_t := default_session + ) return integer_array_t is + begin + if p_eval(expr, p_kind_integer_array, -1, p_eval_operation(expr, session), session) then + return p_result_integer_array; + end if; + return null_integer_array; + end; + + procedure eval_std_logic_vector( + expr : string; result : out std_logic_vector; session : python_session_t := default_session + ) is + begin + if p_eval(expr, p_kind_std_logic_vector, result'length, p_eval_operation(expr, session), session) then + result := p_to_std_logic_vector(p_result_string); + end if; + end; + + procedure eval_signed( + expr : string; result : out signed; session : python_session_t := default_session + ) is + begin + if p_eval(expr, p_kind_signed, result'length, p_eval_operation(expr, session), session) then + result := signed(p_to_std_logic_vector(p_result_string)); + end if; + end; + + procedure eval_unsigned( + expr : string; result : out unsigned; session : python_session_t := default_session + ) is + begin + if p_eval(expr, p_kind_unsigned, result'length, p_eval_operation(expr, session), session) then + result := unsigned(p_to_std_logic_vector(p_result_string)); + end if; + end; + + ----------------------------------------------------------------------------- + -- call + ----------------------------------------------------------------------------- + impure function call_real_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real_vector is + begin + return eval_real_vector( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + impure function call_string( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return string is + begin + return eval_string( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + impure function call_integer_vector_ptr( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector_ptr_t is + begin + return eval_integer_vector_ptr( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + impure function call_boolean( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return boolean is + begin + return eval_boolean( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + impure function call_std_logic( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return std_logic is + begin + return eval_std_logic( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + impure function call_std_logic_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return std_logic_vector is + begin + return eval_std_logic_vector( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + impure function call_integer_array( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_array_t is + begin + return eval_integer_array( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + procedure call_std_logic_vector( + identifier : string; result : out std_logic_vector; + arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + eval_std_logic_vector( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + ); + end; + + procedure call_signed( + identifier : string; result : out signed; + arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + eval_signed( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + ); + end; + + procedure call_unsigned( + identifier : string; result : out unsigned; + arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + eval_unsigned( + p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + ); + end; +end package body; diff --git a/vunit/vhdl/python/src/python_pkg_vhpi.c b/vunit/vhdl/python/src/python_pkg_vhpi.c new file mode 100644 index 000000000..e4eac555a --- /dev/null +++ b/vunit/vhdl/python/src/python_pkg_vhpi.c @@ -0,0 +1,366 @@ +// This package provides a dictionary types and operations +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2014-2023, Lars Asplund lars.anders.asplund@gmail.com + +#include +#include +#include + +#include "python_pkg.h" + +PyObject* globals = NULL; +PyObject* locals = NULL; + +#define MAX_VHDL_PARAMETER_STRING_LENGTH 100000 + +PLI_VOID python_cleanup(const struct vhpiCbDataS* cb_p); + +static void py_error_handler(const char* context, const char* code_or_expr, + const char* reason, bool cleanup) { + const char* unknown_error = "Unknown error"; + + // Use provided error reason or try extracting the reason from the Python + // exception + if (reason == NULL) { + PyObject *ptype, *pvalue, *ptraceback; + PyErr_Fetch(&ptype, &pvalue, &ptraceback); + if (ptype != NULL) { + reason = get_string(pvalue); + } + // The reason has been extracted. The exception must not be left set: + // Py_FinalizeEx in python_cleanup would fail on it. + Py_XDECREF(ptype); + Py_XDECREF(pvalue); + Py_XDECREF(ptraceback); + } + + // Clean-up Python session first in case vhpi_assert stops the simulation + if (cleanup) { + python_cleanup(NULL); + } + + // Output error message + reason = reason == NULL ? unknown_error : reason; + if (code_or_expr == NULL) { + vhpi_assert(vhpiError, "ERROR %s:\n\n%s\n\n", context, reason); + } else { + vhpi_assert(vhpiError, "ERROR %s:\n\n%s\n\n%s\n\n", context, code_or_expr, + reason); + } + + // Stop the simulation if vhpi_assert didn't. + vhpi_control(vhpiStop); +} + +static void ffi_error_handler(const char* context, bool cleanup) { + vhpiErrorInfoT err; + + // Clean-up Python session first in case vhpi_assert stops the simulation + if (cleanup) { + python_cleanup(NULL); + } + + if (vhpi_check_error(&err)) { + vhpi_assert(err.severity, "ERROR %s: \n\n%s (%d): %s\n\n", context, + err.file, err.line, err.message); + + } else { + vhpi_assert(vhpiError, "ERROR %s\n\n", context); + } + + // Stop the simulation if vhpi_assert didn't. + vhpi_control(vhpiStop); +} + +PLI_VOID python_setup(const struct vhpiCbDataS* cb_p) { + Py_Initialize(); + if (!Py_IsInitialized()) { + ffi_error_handler("Failed to initialize Python", false); + } + + PyObject* main_module = PyImport_AddModule("__main__"); + if (main_module == NULL) { + ffi_error_handler("Failed to get the main module", true); + } + + globals = PyModule_GetDict(main_module); + if (globals == NULL) { + ffi_error_handler("Failed to get the global dictionary", true); + } + + // globals and locals are the same at the top-level + locals = globals; + + register_py_error_handler(py_error_handler); + register_ffi_error_handler(ffi_error_handler); +} + +PLI_VOID python_cleanup(const struct vhpiCbDataS* cb_p) { + if (!Py_IsInitialized()) { + // The interpreter was never started, there is nothing to release + return; + } + + if (locals != NULL) { + Py_DECREF(locals); + } + + if (Py_FinalizeEx()) { + vhpi_assert(vhpiWarning, "WARNING: Failed to finalize Python"); + } +} + +// python_setup is optional: every operation starts the interpreter if it is +// not already running. +static void ensure_initialized(void) { + if (!Py_IsInitialized()) { + python_setup(NULL); + } +} + +static const char* get_parameter(const struct vhpiCbDataS* cb_p) { + // Get parameter from VHDL function call + vhpiHandleT parameter_handle = + vhpi_handle_by_index(vhpiParamDecls, cb_p->obj, 0); + if (parameter_handle == NULL) { + ffi_error_handler("getting VHDL parameter handle", true); + } + + vhpiValueT parameter; + static char vhdl_parameter_string[MAX_VHDL_PARAMETER_STRING_LENGTH]; + + parameter.bufSize = MAX_VHDL_PARAMETER_STRING_LENGTH; + parameter.value.str = vhdl_parameter_string; + parameter.format = vhpiStrVal; + + if (vhpi_get_value(parameter_handle, ¶meter)) { + ffi_error_handler("getting VHDL parameter value", true); + } + + vhpi_release_handle(parameter_handle); + + return vhdl_parameter_string; +} + +PLI_VOID eval_integer(const struct vhpiCbDataS* cb_p) { + ensure_initialized(); + + // Get expression parameter from VHDL function call + const char* expr = get_parameter(cb_p); + + // Eval(uate) expression in Python + PyObject* eval_result = eval(expr); + + // Return result to VHDL + vhpiValueT vhdl_result; + vhdl_result.format = vhpiIntVal; + vhdl_result.bufSize = 0; + vhdl_result.value.intg = get_integer(eval_result, expr, true); + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiDeposit)) { + py_error_handler("returning result for evaluation of", expr, NULL, true); + } +} + +PLI_VOID eval_real(const struct vhpiCbDataS* cb_p) { + ensure_initialized(); + + // Get expression parameter from VHDL function call + const char* expr = get_parameter(cb_p); + + // Eval(uate) expression in Python + PyObject* eval_result = eval(expr); + + // Return result to VHDL + vhpiValueT vhdl_result; + vhdl_result.format = vhpiRealVal; + vhdl_result.bufSize = 0; + vhdl_result.value.real = get_real(eval_result, expr, true); + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiDeposit)) { + py_error_handler("returning result for evaluation of", expr, NULL, true); + } +} + +PLI_VOID eval_integer_vector(const struct vhpiCbDataS* cb_p) { + ensure_initialized(); + + // Get expression parameter from VHDL function call + const char* expr = get_parameter(cb_p); + + // Eval(uate) expression in Python + PyObject* pyobj = eval(expr); + + // Check that the eval results in a list. TODO: tuple and sets of integers + // should also work + if (!PyList_Check(pyobj)) { + handle_type_check_error(pyobj, "evaluating to integer_vector", expr); + } + + const int list_size = PyList_GET_SIZE(pyobj); + const int n_bytes = list_size * sizeof(int); + int* int_array = (int*)malloc(n_bytes); + + for (int idx = 0; idx < list_size; idx++) { + int_array[idx] = get_integer(PyList_GetItem(pyobj, idx), expr, false); + } + Py_DECREF(pyobj); + + // Return result to VHDL + vhpiValueT vhdl_result; + vhdl_result.format = vhpiIntVecVal; + vhdl_result.bufSize = n_bytes; + vhdl_result.numElems = list_size; + vhdl_result.value.intgs = (vhpiIntT*)int_array; + + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiSizeConstraint)) { + free(int_array); + py_error_handler( + "setting size constraints when returning result for evaluation of", + expr, NULL, true); + } + + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiDeposit)) { + free(int_array); + py_error_handler("returning result for evaluation of", expr, NULL, true); + } + + free(int_array); +} + +PLI_VOID eval_real_vector(const struct vhpiCbDataS* cb_p) { + ensure_initialized(); + + // Get expression parameter from VHDL function call + const char* expr = get_parameter(cb_p); + + // Eval(uate) expression in Python + PyObject* pyobj = eval(expr); + + // Check that the eval results in a list. TODO: tuple and sets of integers + // should also work + if (!PyList_Check(pyobj)) { + handle_type_check_error(pyobj, "evaluating to real_vector", expr); + } + + const int list_size = PyList_GET_SIZE(pyobj); + const int n_bytes = list_size * sizeof(double); + double* double_array = (double*)malloc(n_bytes); + + for (int idx = 0; idx < list_size; idx++) { + double_array[idx] = get_real(PyList_GetItem(pyobj, idx), expr, false); + } + Py_DECREF(pyobj); + + // Return result to VHDL + vhpiValueT vhdl_result; + vhdl_result.format = vhpiRealVecVal; + vhdl_result.bufSize = n_bytes; + vhdl_result.numElems = list_size; + vhdl_result.value.reals = double_array; + + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiSizeConstraint)) { + free(double_array); + py_error_handler( + "setting size constraints when returning result for evaluation of", + expr, NULL, true); + } + + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiDeposit)) { + free(double_array); + py_error_handler("returning result for evaluation of", expr, NULL, true); + } + + free(double_array); +} + +PLI_VOID eval_string(const struct vhpiCbDataS* cb_p) { + ensure_initialized(); + + // Get expression parameter from VHDL function call + const char* expr = get_parameter(cb_p); + + // Eval(uate) expression in Python + PyObject* pyobj = eval(expr); + + char* str = get_string(pyobj); + Py_DECREF(pyobj); + + // Return result to VHDL + vhpiValueT vhdl_result; + vhdl_result.format = vhpiStrVal; + vhdl_result.bufSize = strlen(str) + 1; // null termination included + vhdl_result.numElems = strlen(str); + vhdl_result.value.str = str; + + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiSizeConstraint)) { + py_error_handler( + "setting size constraints when returning result for evaluation of", + expr, NULL, true); + } + + if (vhpi_put_value(cb_p->obj, &vhdl_result, vhpiDeposit)) { + py_error_handler("returning result for evaluation of", expr, NULL, true); + } +} + +PLI_VOID exec(const struct vhpiCbDataS* cb_p) { + ensure_initialized(); + + // Get code parameter from VHDL procedure call + const char* code = get_parameter(cb_p); + + // Exec(ute) Python code + if (PyRun_String(code, Py_file_input, globals, locals) == NULL) { + py_error_handler("executing", code, NULL, true); + } +} + +PLI_VOID register_foreign_subprograms() { + char library_name[] = "python"; + + char python_setup_name[] = "python_setup"; + vhpiForeignDataT python_setup_data = {vhpiProcF, library_name, + python_setup_name, NULL, python_setup}; + // vhpi_assert doesn't seem to work at this point + assert(vhpi_register_foreignf(&python_setup_data) != NULL); + + char python_cleanup_name[] = "python_cleanup"; + vhpiForeignDataT python_cleanup_data = { + vhpiProcF, library_name, python_cleanup_name, NULL, python_cleanup}; + assert(vhpi_register_foreignf(&python_cleanup_data) != NULL); + + char eval_integer_name[] = "eval_integer"; + vhpiForeignDataT eval_integer_data = {vhpiProcF, library_name, + eval_integer_name, NULL, eval_integer}; + assert(vhpi_register_foreignf(&eval_integer_data) != NULL); + + char eval_real_name[] = "eval_real"; + vhpiForeignDataT eval_real_data = {vhpiProcF, library_name, eval_real_name, + NULL, eval_real}; + assert(vhpi_register_foreignf(&eval_real_data) != NULL); + + char eval_integer_vector_name[] = "eval_integer_vector"; + vhpiForeignDataT eval_integer_vector_data = {vhpiProcF, library_name, + eval_integer_vector_name, NULL, + eval_integer_vector}; + assert(vhpi_register_foreignf(&eval_integer_vector_data) != NULL); + + char eval_real_vector_name[] = "eval_real_vector"; + vhpiForeignDataT eval_real_vector_data = { + vhpiProcF, library_name, eval_real_vector_name, NULL, eval_real_vector}; + assert(vhpi_register_foreignf(&eval_real_vector_data) != NULL); + + char eval_string_name[] = "eval_string"; + vhpiForeignDataT eval_string_data = {vhpiProcF, library_name, + eval_string_name, NULL, eval_string}; + assert(vhpi_register_foreignf(&eval_string_data) != NULL); + + char exec_name[] = "exec"; + vhpiForeignDataT exec_data = {vhpiProcF, library_name, exec_name, NULL, exec}; + assert(vhpi_register_foreignf(&exec_data) != NULL); +} + +PLI_VOID (*vhpi_startup_routines[])() = {register_foreign_subprograms, NULL}; diff --git a/vunit/vhdl/python/src/python_pkg_vhpi.vhd b/vunit/vhdl/python/src/python_pkg_vhpi.vhd new file mode 100644 index 000000000..4d6f3a606 --- /dev/null +++ b/vunit/vhdl/python/src/python_pkg_vhpi.vhd @@ -0,0 +1,220 @@ +-- This package provides a dictionary types and operations +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +use std.textio.all; + +use work.integer_array_pkg.all; +use work.logger_pkg.all; + +package python_ffi_pkg is + -- A session is a named Python namespace. Only the default session, the + -- __main__ namespace, is supported by this foreign language interface. + type python_session_t is array (positive range <>) of character; + constant default_session : python_session_t := "default"; + + procedure python_setup; + -- TODO: Looks like Riviera-PRO requires the path to the shared library to be fixed at compile time + -- and that may become a bit limited. VHDL standard allow for expressions. + attribute foreign of python_setup : procedure is "VHPI libraries/python python_setup"; + procedure python_cleanup; + attribute foreign of python_cleanup : procedure is "VHPI libraries/python python_cleanup"; + + function p_eval_integer(expr : string) return integer; + attribute foreign of p_eval_integer : function is "VHPI libraries/python eval_integer"; + function eval_integer(expr : string; session : python_session_t := default_session) return integer; + alias eval is eval_integer[string, python_session_t return integer]; + + function p_eval_real(expr : string) return real; + attribute foreign of p_eval_real : function is "VHPI libraries/python eval_real"; + function eval_real(expr : string; session : python_session_t := default_session) return real; + alias eval is eval_real[string, python_session_t return real]; + + function p_eval_integer_vector(expr : string) return integer_vector; + attribute foreign of p_eval_integer_vector : function is "VHPI libraries/python eval_integer_vector"; + function eval_integer_vector( + expr : string; session : python_session_t := default_session + ) return integer_vector; + alias eval is eval_integer_vector[string, python_session_t return integer_vector]; + + function p_eval_real_vector(expr : string) return real_vector; + attribute foreign of p_eval_real_vector : function is "VHPI libraries/python eval_real_vector"; + function eval_real_vector( + expr : string; session : python_session_t := default_session + ) return real_vector; + alias eval is eval_real_vector[string, python_session_t return real_vector]; + + function p_eval_string(expr : string) return string; + attribute foreign of p_eval_string : function is "VHPI libraries/python eval_string"; + function eval_string(expr : string; session : python_session_t := default_session) return string; + alias eval is eval_string[string, python_session_t return string]; + + procedure p_exec(code : string); + attribute foreign of p_exec : procedure is "VHPI libraries/python exec"; + procedure exec(code : string; session : python_session_t := default_session); + + ----------------------------------------------------------------------------- + -- Private, the primitives the bridge operations of python_pkg are built on + ----------------------------------------------------------------------------- + -- Some operations of python_pkg are implemented by the VUnit Python + -- bridge, which is only available for NVC, GHDL and Questa. The primitives + -- below are declared so that python_pkg has one body for every simulator, + -- but they report a failure when they are used. + + -- Logger used to report Python errors + constant python_logger : logger_t := get_logger("vunit_lib:python"); + + -- Result kinds, must match vunit/python_bridge/runtime.py + constant p_kind_integer : integer := 0; + constant p_kind_real : integer := 1; + constant p_kind_boolean : integer := 2; + constant p_kind_string : integer := 3; + constant p_kind_std_logic : integer := 4; + constant p_kind_std_logic_vector : integer := 5; + constant p_kind_signed : integer := 6; + constant p_kind_unsigned : integer := 7; + constant p_kind_integer_array : integer := 8; + constant p_kind_integer_vector : integer := 9; + constant p_kind_real_vector : integer := 10; + + -- Name of the operation, used in the error messages + function p_eval_operation(expr : string; session : python_session_t := default_session) return string; + + -- Execute the Python file with the given name + impure function p_exec_file( + file_name : string; session : python_session_t := default_session + ) return boolean; + + -- Evaluate expr and convert the value to the VHDL type given by kind. width + -- is the length of a std_logic_vector, signed or unsigned result, -1 when + -- it is not known. + impure function p_eval( + expr : string; + kind : integer; + width : integer; + operation : string; + session : python_session_t := default_session + ) return boolean; + + -- The result of the last evaluation + impure function p_result_integer return integer; + impure function p_result_string return string; + impure function p_result_integer_array return integer_array_t; + + -- Transfer an integer_array_t to Python and return the id it is staged + -- under, -1 on failure. + impure function p_stage_array(arr : integer_array_t; operation : string) return integer; +end package; + +package body python_ffi_pkg is + procedure p_check_session(session : python_session_t) is + begin + if session /= default_session then + report "Python sessions are only supported with NVC, GHDL and Questa" severity failure; + end if; + end; + + function eval_integer(expr : string; session : python_session_t := default_session) return integer is + begin + p_check_session(session); + return p_eval_integer(expr); + end; + + function eval_real(expr : string; session : python_session_t := default_session) return real is + begin + p_check_session(session); + return p_eval_real(expr); + end; + + function eval_integer_vector( + expr : string; session : python_session_t := default_session + ) return integer_vector is + begin + p_check_session(session); + return p_eval_integer_vector(expr); + end; + + function eval_real_vector( + expr : string; session : python_session_t := default_session + ) return real_vector is + begin + p_check_session(session); + return p_eval_real_vector(expr); + end; + + function eval_string(expr : string; session : python_session_t := default_session) return string is + begin + p_check_session(session); + return p_eval_string(expr); + end; + + procedure exec(code : string; session : python_session_t := default_session) is + begin + p_check_session(session); + p_exec(code); + end; + + ----------------------------------------------------------------------------- + -- Private, the primitives the bridge operations of python_pkg are built on + ----------------------------------------------------------------------------- + procedure p_unsupported(name : string) is + begin + failure(python_logger, name & " requires NVC, GHDL or Questa"); + end; + + function p_eval_operation(expr : string; session : python_session_t := default_session) return string is + begin + if session = default_session then + return "eval(""" & expr & """)"; + end if; + return "eval(""" & expr & """, session => """ & string(session) & """)"; + end; + + impure function p_exec_file( + file_name : string; session : python_session_t := default_session + ) return boolean is + begin + p_unsupported("exec_file"); + return false; + end; + + impure function p_eval( + expr : string; + kind : integer; + width : integer; + operation : string; + session : python_session_t := default_session + ) return boolean is + begin + p_unsupported(operation); + return false; + end; + + impure function p_result_integer return integer is + begin + p_unsupported("p_result_integer"); + return integer'low; + end; + + impure function p_result_string return string is + begin + p_unsupported("p_result_string"); + return ""; + end; + + impure function p_result_integer_array return integer_array_t is + begin + p_unsupported("p_result_integer_array"); + return null_integer_array; + end; + + impure function p_stage_array(arr : integer_array_t; operation : string) return integer is + begin + p_unsupported(operation); + return -1; + end; +end package body; diff --git a/vunit/vhdl/python/test/models/counter.py b/vunit/vhdl/python/test/models/counter.py new file mode 100644 index 000000000..6a3751989 --- /dev/null +++ b/vunit/vhdl/python/test/models/counter.py @@ -0,0 +1,18 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Fixture used to test that re-executing a Python file executes it again +(rather than being a no-op import). +""" + +if "CALL_COUNT" not in globals(): + CALL_COUNT = 0 +CALL_COUNT += 1 + + +def get_call_count(): + return CALL_COUNT diff --git a/vunit/vhdl/python/test/models/filters.py b/vunit/vhdl/python/test/models/filters.py new file mode 100644 index 000000000..8f0d33f2e --- /dev/null +++ b/vunit/vhdl/python/test/models/filters.py @@ -0,0 +1,14 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Fixture used to test that a Python file executed by python_execute can import +a sibling module (from the same directory) without PYTHONPATH being set. +""" + + +def fir(x): + return x + 1 diff --git a/vunit/vhdl/python/test/models/importer.py b/vunit/vhdl/python/test/models/importer.py new file mode 100644 index 000000000..cbe0c4ae0 --- /dev/null +++ b/vunit/vhdl/python/test/models/importer.py @@ -0,0 +1,29 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Fixture used to test sibling import (from filters import fir # type: ignore[import-not-found]) without +PYTHONPATH, and use of an inline-defined name (GAIN) from a file executed +later. +""" + +from pathlib import Path + +from filters import fir # type: ignore[import-not-found] + +MODEL_DIR = str(Path(__file__).parent) + + +def scaled(x): + # GAIN is expected to already be defined in the namespace by an earlier + # inline python_execute(source => ...) call. + return fir(x) * GAIN # noqa: F821 + + +def dir_in_syspath(): + import sys + + return MODEL_DIR in sys.path diff --git a/vunit/vhdl/python/test/models/reference_model.py b/vunit/vhdl/python/test/models/reference_model.py new file mode 100644 index 000000000..647dcd86a --- /dev/null +++ b/vunit/vhdl/python/test/models/reference_model.py @@ -0,0 +1,24 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Fixture used to test python_execute(file_name => ...): __file__ availability +during execution and the relative/absolute file name resolution. +""" + +from pathlib import Path + +# POSIX style, to compare with tb_path on all platforms +FILE_DURING_EXEC = Path(__file__).as_posix() +MODEL_DIR = Path(__file__).parent.as_posix() + + +def get_model_dir(): + return MODEL_DIR + + +def get_file_during_exec(): + return FILE_DURING_EXEC diff --git a/vunit/vhdl/python/test/tb_python_pkg.vhd b/vunit/vhdl/python/test/tb_python_pkg.vhd new file mode 100644 index 000000000..58fff8621 --- /dev/null +++ b/vunit/vhdl/python/test/tb_python_pkg.vhd @@ -0,0 +1,395 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +library vunit_lib; +context vunit_lib.vunit_context; +-- Keep this as an add-on while developing +-- but eventually I think it should be included +-- by default for supporting simulators +context vunit_lib.python_context; + +library ieee; +use ieee.math_real.all; + +entity tb_python_pkg is + generic(runner_cfg : string); +end entity; + +architecture tb of tb_python_pkg is + constant integer_range : string := + "(" & integer'image(integer'low) & " to " & integer'image(integer'high) & ")"; +begin + test_runner : process + constant empty_integer_vector : integer_vector(0 downto 1) := (others => 0); + constant empty_real_vector : real_vector(0 downto 1) := (others => 0.0); + constant test_real_vector : real_vector := (-3.4028234664e38, -1.9, 0.0, 1.1, -3.4028234664e38); + + variable vhdl_int : integer; + variable vhdl_real : real; + variable vhdl_real_vector : real_vector(test_real_vector'range); + variable vhdl_integer_vector_ptr : integer_vector_ptr_t; + variable vhdl_integer_vector : integer_vector(0 to 3); + variable arg_value2 : arg_t(name(1 to 2), value(1 to 2)); + variable arg_value4 : arg_t(name(1 to 2), value(1 to 4)); + variable arg_value22 : arg_t(name(1 to 2), value(1 to 22)); + + procedure print(a : arg_t) is + begin + print(a.name); + print(a.value); + end; + + -- Python function returning the error text that the Python interface + -- reports for a piece of source that fails. It is formatted the way the + -- interface formats it: its own frames are not part of the traceback. + procedure define_error_helper is + begin + exec( + "import linecache, traceback" + + "def expected_error(source, name, is_eval=False):" + + " linecache.cache[name] = (len(source), None, source.splitlines(True), name)" + + " try:" + + " code = compile(source, name, 'eval' if is_eval else 'exec')" + + " eval(code) if is_eval else exec(code)" + + " except BaseException as exc:" + + " return ''.join(traceback.format_exception(type(exc), exc, exc.__traceback__.tb_next)).rstrip('\n')" + ); + end; + + begin + test_runner_setup(runner, runner_cfg); + + show(display_handler, debug); + + while test_suite loop + --------------------------------------------------------------------- + -- Test eval of different types + --------------------------------------------------------------------- + if run("Test eval of integer expression") then + check_equal(eval("17"), 17); + check_equal(eval("2**31 - 1"), integer'high); + check_equal(eval("-2**31"), integer'low); + + elsif run("Test eval of integer with overflow from Python to C") then + mock(python_logger, failure); + vhdl_int := eval("2**63"); + check_only_log( + python_logger, + "eval(""2**63"") failed:" & LF & + "OverflowError: 9223372036854775808 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(python_logger); + + elsif run("Test eval of integer with underflow from Python to C") then + mock(python_logger, failure); + vhdl_int := eval("-2**63 - 1"); + check_only_log( + python_logger, + "eval(""-2**63 - 1"") failed:" & LF & + "OverflowError: -9223372036854775809 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(python_logger); + + elsif run("Test eval of integer with overflow from C to VHDL") then + mock(python_logger, failure); + vhdl_int := eval("2**31"); + check_only_log( + python_logger, + "eval(""2**31"") failed:" & LF & + "OverflowError: 2147483648 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(python_logger); + + elsif run("Test eval of integer with underflow from C to VHDL") then + mock(python_logger, failure); + vhdl_int := eval("-2**31 - 1"); + check_only_log( + python_logger, + "eval(""-2**31 - 1"") failed:" & LF & + "OverflowError: -2147483649 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(python_logger); + + elsif run("Test eval of real expression") then + check_equal(eval("3.40282346e38"), 3.40282346e38); + check_equal(eval("1.1754943508e-38"), 1.1754943508e-38); + check_equal(eval("-3.4028e38"), -3.4028e38); + check_equal(eval("-1.1754943508e-38"), -1.1754943508e-38); + + elsif run("Test eval of real with overflow from C to VHDL") then + -- VHDL real is double precision where the Python bridge implements + -- the API, so any finite Python float is in range + check_equal(eval("1.0e300"), 1.0e300); + + -- A value that is not finite has no VHDL real representation + mock(python_logger, failure); + vhdl_real := eval("float('inf')"); + check_only_log( + python_logger, + "eval(""float('inf')"") failed:" & LF & + "ValueError: inf cannot be represented as VHDL real", + failure + ); + unmock(python_logger); + + elsif run("Test eval of real with underflow from C to VHDL") then + check_equal(eval("-1.0e300"), -1.0e300); + + mock(python_logger, failure); + vhdl_real := eval("-float('inf')"); + check_log( + python_logger, + "eval(""-float('inf')"") failed:" & LF & + "ValueError: -inf cannot be represented as VHDL real", + failure + ); + vhdl_real := eval("float('nan')"); + check_only_log( + python_logger, + "eval(""float('nan')"") failed:" & LF & + "ValueError: nan cannot be represented as VHDL real", + failure + ); + unmock(python_logger); + + elsif run("Test converting integer_vector to Python list string") then + check_equal(to_py_list_str(empty_integer_vector), "[]"); + check_equal(to_py_list_str(integer_vector'(0 => 1)), "[1]"); + check_equal(to_py_list_str(integer_vector'(-1, 0, 1)), "[-1,0,1]"); + + elsif run("Test eval of integer_vector expression") then + check(eval(to_py_list_str(empty_integer_vector)) = empty_integer_vector); + check(eval(to_py_list_str(integer_vector'(0 => 17))) = integer_vector'(0 => 17)); + check(eval(to_py_list_str(integer_vector'(integer'low, -1, 0, 1, integer'high))) = + integer_vector'(integer'low, -1, 0, 1, integer'high) + ); + + elsif run("Test converting integer_vector_ptr to Python list string") then + vhdl_integer_vector_ptr := new_integer_vector_ptr; + check_equal(to_py_list_str(vhdl_integer_vector_ptr), "[]"); + + vhdl_integer_vector_ptr := new_integer_vector_ptr(1); + set(vhdl_integer_vector_ptr, 0, 1); + check_equal(to_py_list_str(vhdl_integer_vector_ptr), "[1]"); + + vhdl_integer_vector_ptr := new_integer_vector_ptr(3); + for idx in 0 to 2 loop + set(vhdl_integer_vector_ptr, idx, idx - 1); + end loop; + check_equal(to_py_list_str(vhdl_integer_vector_ptr), "[-1,0,1]"); + + elsif run("Test eval of integer_vector_ptr expression") then + check_equal(length(eval(to_py_list_str(new_integer_vector_ptr))), 0); + + vhdl_integer_vector_ptr := eval(to_py_list_str(integer_vector'(0 => 17))); + check_equal(get(vhdl_integer_vector_ptr, 0), 17); + + vhdl_integer_vector_ptr := eval(to_py_list_str(integer_vector'(integer'low, -1, 0, 1, integer'high))); + check_equal(get(vhdl_integer_vector_ptr, 0), integer'low); + check_equal(get(vhdl_integer_vector_ptr, 1), -1); + check_equal(get(vhdl_integer_vector_ptr, 2), 0); + check_equal(get(vhdl_integer_vector_ptr, 3), 1); + check_equal(get(vhdl_integer_vector_ptr, 4), integer'high); + + elsif run("Test eval of string expression") then + check_equal(eval("''"), string'("")); + check_equal(eval("'\\'"), string'("\")); + check_equal(eval_string("'Hello from VUnit'"), "Hello from VUnit"); + check_equal(eval_string("'Hello\nWorld'"), "Hello" & LF & "World"); + + elsif run("Test converting real_vector to Python list string") then + check_equal(to_py_list_str(empty_real_vector), "[]"); + check_equal(to_py_list_str(real_vector'(0 => 1.05)), "[1.0500000000000000e+00]"); + check_equal(to_py_list_str(real_vector'(-1.05, 0.0, 1.25)), + "[-1.0500000000000000e+00,0.0000000000000000e+00,1.2500000000000000e+00]"); + + elsif run("Test eval of real_vector expression") then + check(eval(to_py_list_str(empty_real_vector)) = empty_real_vector); + check(eval(to_py_list_str(real_vector'(0 => 17.0))) = real_vector'(0 => 17.0)); + vhdl_real_vector := eval(to_py_list_str(test_real_vector)); + for idx in vhdl_real_vector'range loop + check_equal(vhdl_real_vector(idx), vhdl_real_vector(idx)); + end loop; + + --------------------------------------------------------------------- + -- Test exec + --------------------------------------------------------------------- + elsif run("Test basic exec") then + exec("py_int = 21"); + check_equal(eval("py_int"), 21); + + elsif run("Test exec with multiple code snippets separated by a semicolon") then + exec("a = 1; b = 2"); + check_equal(eval("a"), 1); + check_equal(eval("b"), 2); + + elsif run("Test exec with multiple code snippets separated by a newline") then + exec( + "a = 1" & LF & + "b = 2" + ); + check_equal(eval("a"), 1); + check_equal(eval("b"), 2); + + elsif run("Test exec with code construct with indentation") then + exec( + "a = [None] * 2" & LF & + "for idx in range(len(a)):" & LF & + " a[idx] = idx" + ); + + check_equal(eval("a[0]"), 0); + check_equal(eval("a[1]"), 1); + + elsif run("Test a simpler multiline syntax") then + exec( + "a = [None] * 2" + + "for idx in range(len(a)):" + + " a[idx] = idx" + ); + + check_equal(eval("a[0]"), 0); + check_equal(eval("a[1]"), 1); + + elsif run("Test exec of locally defined function") then + exec( + "def local_test():" & LF & + " return 1" + ); + + check_equal(eval("local_test()"), 1); + + elsif run("Test call functions") then + check_equal(call("len", arg("Hello")), 5); + check_equal(call("len", arg(integer_vector'(1, 2, 3))), 3); + check_equal(call("max", arg(1), arg(2)), 2); + check_equal(call("max", arg(2.1), arg(1.1)), 2.1); + check_equal(call("int", arg(true)), 1); + + elsif run("Test call procedure") then + exec("l = [1]"); + call("l.append", arg(2)); + check_equal(eval("l[1]"), 2); + + elsif run("Test kwarg") then + arg_value2 := kwarg("kw", 17); + check_equal(arg_value2.name, "kw"); + check_equal(arg_value2.value, "17"); + + arg_value22 := kwarg("kw", 17.5); + check_equal(arg_value22.name, "kw"); + check_equal(arg_value22.value, "1.7500000000000000e+01"); + + arg_value4 := kwarg("kw", "ok"); + check_equal(arg_value4.name, "kw"); + check_equal(arg_value4.value, """ok"""); + + arg_value4 := kwarg("kw", true); + check_equal(arg_value4.name, "kw"); + check_equal(arg_value4.value, "True"); + + arg_value22 := kwarg("kw", integer_vector'(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)); + check_equal(arg_value22.name, "kw"); + check_equal(arg_value22.value, "[1,2,3,4,5,6,7,8,9,10]"); + + + elsif run("Test exec of function defined in run script") then + import_run_script; + check_equal(eval("run.remote_test()"), 2); + + import_run_script("my_run_script"); + check_equal(eval("my_run_script.remote_test()"), 2); + + exec("from my_run_script import remote_test"); + check_equal(eval("remote_test()"), 2); + + --------------------------------------------------------------------- + -- Test error handling + --------------------------------------------------------------------- + elsif run("Test exceptions in exec") then + define_error_helper; + -- A multiline string argument cannot be passed verbatim to Python, so + -- the source of the failing exec is kept in a Python variable + exec( + "failing_source = 'doing_something_right = 17\n" & + "doing_something_wrong = doing_something_right_misspelled'" + ); + + mock(python_logger, failure); + exec( + "doing_something_right = 17" & LF & + "doing_something_wrong = doing_something_right_misspelled" + ); + check_only_log( + python_logger, + "exec failed:" & LF & eval_string("expected_error(failing_source, '')"), + failure + ); + unmock(python_logger); + + elsif run("Test exceptions in eval") then + define_error_helper; + + mock(python_logger, failure); + vhdl_int := eval("1 / 0"); + check_only_log( + python_logger, + "eval(""1 / 0"") failed:" & LF & + call_string( + "expected_error", arg(string'("1 / 0")), arg(string'("")), arg(true) + ), + failure + ); + unmock(python_logger); + + elsif run("Test eval with type error") then + mock(python_logger, failure); + vhdl_int := eval("10 / 2"); + check_only_log( + python_logger, + "eval(""10 / 2"") failed:" & LF & + "TypeError: Cannot convert Python float (5.0) to VHDL integer; expected int", + failure + ); + unmock(python_logger); + + elsif run("Test raising exception") then + define_error_helper; + + mock(python_logger, failure); + exec("raise RuntimeError('An exception')"); + check_only_log( + python_logger, + "exec failed:" & LF & + call_string( + "expected_error", + arg(string'("raise RuntimeError('An exception')")), + arg(string'("")) + ), + failure + ); + unmock(python_logger); + + --------------------------------------------------------------------- + -- Misc tests + --------------------------------------------------------------------- + elsif run("Test globals and locals") then + exec("assert(globals() == locals())"); + + elsif run("Test print flushing") then + exec("print('Flushed by the print call', flush=True)"); + exec("print('Flushed by the Python interface')"); + + end if; + end loop; + + test_runner_cleanup(runner); + end process; +end; diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd new file mode 100644 index 000000000..b97b8c445 --- /dev/null +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -0,0 +1,908 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com +-- +-- Tests of the python_pkg operations that are implemented by the Python +-- bridge and therefore only available for NVC and GHDL. The rest of the +-- package is tested by tb_python_pkg. + +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +library vunit_lib; +context vunit_lib.vunit_context; +context vunit_lib.python_context; + +entity tb_python_pkg_bridge is + generic (runner_cfg : string); +end entity; + +architecture tb of tb_python_pkg_bridge is + constant std_logic_characters : string(1 to 9) := "UX01ZWLH-"; + constant group_error : string := "Only keyword arguments can be combined with & into a keyword argument group"; +begin + main : process + constant golden : python_session_t := "golden"; + constant fixed_point : python_session_t := "fixed_point"; + variable arr, arr_b, result : integer_array_t; + variable ptr : integer_vector_ptr_t; + variable slv4 : std_logic_vector(3 downto 0); + variable slv9_desc : std_logic_vector(8 downto 0) := "UX01ZWLH-"; + variable slv9_asc : std_logic_vector(0 to 8) := "UX01ZWLH-"; + variable result_vec9 : std_logic_vector(8 downto 0); + variable s8 : signed(7 downto 0); + variable u8 : unsigned(7 downto 0); + variable u1 : unsigned(0 downto 0) := "1"; + variable u32 : unsigned(31 downto 0) := x"DEADBEEF"; + variable u64 : unsigned(63 downto 0) := (others => '1'); + variable u128 : unsigned(127 downto 0) := (others => '1'); + variable s64 : signed(63 downto 0) := x"8000000000000000"; + variable u_null : unsigned(0 downto 1); + variable s_null : signed(0 downto 1); + variable discard_int : integer; + variable reals : real_vector(0 to 2); + + -- Evaluations whose result is thrown away after a mocked failure + procedure discard(value : integer_vector) is + begin + end; + + procedure discard(value : real_vector) is + begin + end; + + function ends_with(value, suffix : string) return boolean is + alias normalized : string(1 to value'length) is value; + begin + if value'length < suffix'length then + return false; + end if; + return normalized(value'length - suffix'length + 1 to value'length) = suffix; + end; + + -- Python function returning the error text that the bridge reports for a + -- piece of source that fails. It is formatted the way the bridge formats + -- it: the frames of the bridge itself are not part of the traceback. + procedure define_error_helper is + begin + exec( + "import linecache, traceback" + + "def expected_error(source, name, is_eval=False):" + + " linecache.cache[name] = (len(source), None, source.splitlines(True), name)" + + " try:" + + " code = compile(source, name, 'eval' if is_eval else 'exec')" + + " eval(code) if is_eval else exec(code)" + + " except BaseException as exc:" + + " return ''.join(traceback.format_exception(type(exc), exc, exc.__traceback__.tb_next)).rstrip('\n')" + ); + end; + + -- Python function showing the decimal value of its argument + procedure define_as_str is + begin + exec("def as_str(x):" + " return str(x)"); + end; + + -- Python function returning one of its keyword arguments + procedure define_pick is + begin + exec("def pick(**kwargs):" + " return kwargs['v']"); + end; + + -- Python function showing the source text of the arguments it is called with + procedure define_describe is + begin + exec( + "def describe(*args, **kwargs):" + + " return ', '.join([repr(a) for a in args] + [f'{k}={v!r}' for k, v in kwargs.items()])" + ); + end; + + -- The same staged array used in two calls. The function modifies its + -- argument in place but every use gets its own copy. + procedure bump_twice(data : arg_t) is + begin + check_equal(integer'(call("bump", data)), 5); + check_equal(integer'(call("bump", data)), 5); + end; + + begin + test_runner_setup(runner, runner_cfg); + + while test_suite loop + + --------------------------------------------------------------------- + -- exec and exec_file + --------------------------------------------------------------------- + if run("Test inline source") then + exec("X = 6 * 7"); + exec("def get_x():" & LF & " return X"); + check_equal(integer'(call("get_x")), 42); + + elsif run("Test multiline source with blank lines and indentation") then + exec( + "def double(x):" & LF & + "" & LF & + " " & LF & + " return x * 2" & LF & + "" + ); + check_equal(integer'(call("double", arg(21))), 42); + check_equal(integer'(call("double", arg(-5))), -10); + + elsif run("Test that + joins source lines") then + check_equal(string'("a" + "b"), "a" & LF & "b"); + check_equal(string'("a" + "" + "b"), "a" & LF & LF & "b"); + exec( + "def triple(x):" + + "" + + " return x * 3" + ); + check_equal(integer'(call("triple", arg(14))), 42); + + elsif run("Test that + on strings does not disturb numeric_std arithmetic") then + check_equal(to_integer(unsigned'("0011") + "0001"), 4); + check_equal(to_integer(signed'("1110") + signed'("0001")), -1); + + elsif run("Test persistent namespace across exec and call") then + exec("COUNTER = 0"); + exec( + "def inc():" + + " global COUNTER" + + " COUNTER += 1" + + " return COUNTER" + ); + check_equal(integer'(call("inc")), 1); + check_equal(integer'(call("inc")), 2); + check_equal(integer'(call("inc")), 3); + + elsif run("Test executing a file with a relative file name") then + exec_file("test/models/reference_model.py"); + check_equal(call_string("get_model_dir"), tb_path(runner_cfg) & "models"); + + elsif run("Test executing a file with an absolute file name") then + exec_file(tb_path(runner_cfg) & "models/reference_model.py"); + check_equal(call_string("get_model_dir"), tb_path(runner_cfg) & "models"); + + elsif run("Test that __file__ is set during file execution and restored after") then + exec_file("test/models/reference_model.py"); + check_equal(call_string("get_file_during_exec"), tb_path(runner_cfg) & "models/reference_model.py"); + exec("def after_file_absent():" + " return '__file__' not in globals()"); + check_true(call_boolean("after_file_absent")); + + elsif run("Test sibling import without PYTHONPATH and that sys.path is restored") then + exec("GAIN = 3"); + exec_file("test/models/importer.py"); + check_equal(integer'(call("scaled", arg(2))), 9); -- fir(2) = 3, 3 * GAIN(3) = 9 + check_false(call_boolean("dir_in_syspath")); + + elsif run("Test that re-executing a file executes it again") then + exec_file("test/models/counter.py"); + exec_file("test/models/counter.py"); + check_equal(integer'(call("get_call_count")), 2); + + elsif run("Test that exec_file with a missing file fails") then + -- The error message shows the path in the native format of the OS + exec("import os" + "def native_repr(path):" + " return repr(os.path.normpath(path))"); + mock(python_logger, failure); + exec_file(tb_path(runner_cfg) & "models/does_not_exist.py"); + check_only_log( + python_logger, + "exec_file(""" & tb_path(runner_cfg) & "models/does_not_exist.py"") failed:" & LF & + "FileNotFoundError: [Errno 2] No such file or directory: " & + call_string("native_repr", arg(string'(tb_path(runner_cfg) & "models/does_not_exist.py"))), + failure + ); + unmock(python_logger); + + elsif run("Test that a Python exception with traceback logs a failure") then + define_error_helper; + mock(python_logger, failure); + exec("raise ValueError('boom')"); + check_only_log( + python_logger, + "exec failed:" & LF & + call_string( + "expected_error", arg(string'("raise ValueError('boom')")), arg(string'("")) + ), + failure + ); + unmock(python_logger); + + elsif run("Test that operations work after python_cleanup") then + exec("cleanup_marker = 1"); + python_cleanup; + exec("cleanup_marker = cleanup_marker + 1"); + check_equal(eval_integer("cleanup_marker"), 2); + + elsif run("Test that the run script path is known to the testbench") then + check_true(ends_with(run_script_path(runner_cfg), "/run.py")); + + --------------------------------------------------------------------- + -- Result types + --------------------------------------------------------------------- + elsif run("Test that the bridge operations keep the other forms unambiguous") then + -- The bridge operations add eval, call, arg and kwarg overloads but + -- must not make any other form ambiguous, not even a string literal + -- argument or an eval whose type comes from the context. + check_equal(eval("17"), 17); + check_equal(eval("3.40282346e38"), 3.40282346e38); + check_equal(eval("''"), string'("")); + check(eval(to_py_list_str(integer_vector'(-1, 0, 1))) = integer_vector'(-1, 0, 1)); + check(eval(to_py_list_str(real_vector'(1.5, -0.25))) = real_vector'(1.5, -0.25)); + ptr := eval(to_py_list_str(integer_vector'(0 => 17))); + check_equal(get(ptr, 0), 17); + reals := eval("[1.5, -3.6, 9.1]"); + check_equal(reals(1), -3.6); + check_equal(call("len", arg("Hello")), 5); + check_equal(call("max", arg(2.1), arg(1.1)), 2.1); + check_equal(call("int", arg(true)), 1); + check_equal(call("round", arg(3.14159), kwarg("ndigits", 3)), 3.142); + exec("l = [1]"); + call("l.append", arg(2)); + check_equal(eval_integer("l[1]"), 2); + check_true(eval("'Yes'") = string'("Yes")); + + elsif run("Test eval of a condition") then + -- eval_boolean makes the result of eval usable as a condition + exec("threshold = 5"); + if eval("True") then + check_true(true); + else + check_failed("eval(""True"") must be true"); + end if; + + if eval("threshold > 10") then + check_failed("eval(""threshold > 10"") must be false"); + end if; + + elsif run("Test call without arguments") then + exec("def answer():" + " return 42"); + check_equal(integer'(call("answer")), 42); + + elsif run("Test integer round trip including bounds") then + exec("def identity(x):" + " return x"); + check_equal(integer'(call("identity", arg(0))), 0); + check_equal(integer'(call("identity", arg(123456))), 123456); + check_equal(integer'(call("identity", arg(integer'low))), integer'low); + check_equal(integer'(call("identity", arg(integer'high))), integer'high); + + elsif run("Test real round trip") then + exec("def identity(x):" + " return x"); + check_equal(real'(call("identity", arg(0.0))), 0.0); + check_equal(real'(call("identity", arg(3.5))), 3.5); + check_equal(real'(call("identity", arg(-2.25))), -2.25); + check_equal(eval_real("1 / 3"), 1.0 / 3.0); + + elsif run("Test that eval of real rejects an int") then + mock(python_logger, failure); + check_equal(eval_real("1"), 0.0); + check_only_log( + python_logger, + "eval(""1"") failed:" & LF & + "TypeError: Cannot convert Python int (1) to VHDL real; expected float", + failure + ); + unmock(python_logger); + + elsif run("Test boolean round trip") then + exec("def identity(x):" + " return x"); + check_true(call_boolean("identity", arg(true))); + check_false(call_boolean("identity", arg(false))); + check_true(eval_boolean("1 < 2")); + + elsif run("Test string round trip including non-ASCII") then + exec("def identity(x):" + " return x"); + check_equal(call_string("identity", arg(string'(""))), string'("")); + check_equal(call_string("identity", arg(string'("hello"))), string'("hello")); + check_equal(call_string("identity", arg(string'("café å"))), string'("café å")); + + elsif run("Test std_logic round trip of all 9 states") then + exec("def identity(x):" + " return x"); + for idx in std_logic_characters'range loop + check_equal( + call_std_logic("identity", arg(string'(1 => std_logic_characters(idx)))), + std_logic'val(idx - 1) + ); + end loop; + + elsif run("Test std_logic_vector round trip with descending range") then + exec("def identity(x):" + " return x"); + result_vec9 := call_std_logic_vector("identity", arg(to_string(slv9_desc))); + check_equal(result_vec9, slv9_desc); + + elsif run("Test std_logic_vector round trip with ascending range") then + exec("def identity(x):" + " return x"); + call_std_logic_vector("identity", result_vec9, arg(to_string(slv9_asc))); + check_equal(result_vec9, slv9_asc); + + elsif run("Test signed and unsigned procedure results including exact bounds") then + exec("def identity(x):" + " return x"); + + call_signed("identity", s8, arg(-128)); + check_equal(s8, to_signed(-128, 8)); + + call_signed("identity", s8, arg(127)); + check_equal(s8, to_signed(127, 8)); + + call_unsigned("identity", u8, arg(0)); + check_equal(u8, to_unsigned(0, 8)); + + call_unsigned("identity", u8, arg(255)); + check_equal(u8, to_unsigned(255, 8)); + + eval_signed("-1", s8); + check_equal(s8, to_signed(-1, 8)); + + eval_unsigned("2 ** 8 - 1", u8); + check_equal(u8, to_unsigned(255, 8)); + + elsif run("Test integer_vector and real_vector results") then + exec("def identity(x):" + " return x"); + reals := call_real_vector("identity", arg(real_vector'(1.5, -0.25, 0.0))); + check_equal(reals(0), 1.5); + check_equal(reals(1), -0.25); + check_equal(reals(2), 0.0); + + ptr := call_integer_vector_ptr("identity", arg(integer_vector'(-1, 0, 1))); + check_equal(length(ptr), 3); + check_equal(get(ptr, 0), -1); + check_equal(get(ptr, 2), 1); + + elsif run("Test that eval of integer_vector is strict about element types") then + mock(python_logger, failure); + discard(eval_integer_vector("[1, 2.0]")); + check_log( + python_logger, + "eval(""[1, 2.0]"") failed:" & LF & + "TypeError: Cannot convert element 1 of the Python list, a float (2.0), " & + "to a VHDL integer; expected int", + failure + ); + discard(eval_integer_vector("[True]")); + check_log( + python_logger, + "eval(""[True]"") failed:" & LF & + "TypeError: Cannot convert element 0 of the Python list, a bool (True), " & + "to a VHDL integer; expected int", + failure + ); + discard(eval_integer_vector("17")); + check_only_log( + python_logger, + "eval(""17"") failed:" & LF & + "TypeError: Cannot convert Python int (17) to VHDL integer_vector; expected a list or tuple", + failure + ); + unmock(python_logger); + + elsif run("Test that eval of real_vector is strict about element types") then + reals := eval_real_vector("(1.5, -0.25, 0.0)"); + check_equal(reals(1), -0.25); + + mock(python_logger, failure); + discard(eval_real_vector("[1.0, 2]")); + check_log( + python_logger, + "eval(""[1.0, 2]"") failed:" & LF & + "TypeError: Cannot convert element 1 of the Python list, a int (2), to a VHDL real; expected float", + failure + ); + discard(eval_real_vector("[True]")); + check_only_log( + python_logger, + "eval(""[True]"") failed:" & LF & + "TypeError: Cannot convert element 0 of the Python list, a bool (True), to a VHDL real; expected float", + failure + ); + unmock(python_logger); + + elsif run("Test that a wrong return type fails") then + exec("def returns_none():" + " return None"); + mock(python_logger, failure); + discard_int := call("returns_none"); + check_only_log( + python_logger, + "eval(""returns_none()"") failed:" & LF & + "TypeError: Cannot convert Python NoneType (None) to VHDL integer; expected int", + failure + ); + unmock(python_logger); + + elsif run("Test that integer overflow fails") then + exec("def too_big():" + " return 2**31"); + mock(python_logger, failure); + discard_int := call("too_big"); + check_only_log( + python_logger, + "eval(""too_big()"") failed:" & LF & + "OverflowError: 2147483648 is outside the range of VHDL integer (-2147483648 to 2147483647)", + failure + ); + unmock(python_logger); + + elsif run("Test that signed overflow in a procedure result fails") then + exec("def identity(x):" + " return x"); + mock(python_logger, failure); + call_signed("identity", s8, arg(200)); + check_only_log( + python_logger, + "eval(""identity(200)"") failed:" & LF & + "OverflowError: 200 does not fit in a 8 bit signed (-128 to 127)", + failure + ); + unmock(python_logger); + + elsif run("Test that unsigned overflow in a procedure result fails") then + exec("def identity(x):" + " return x"); + mock(python_logger, failure); + call_unsigned("identity", u8, arg(-1)); + check_only_log( + python_logger, + "eval(""identity(-1)"") failed:" & LF & + "OverflowError: -1 does not fit in a 8 bit unsigned (0 to 255)", + failure + ); + unmock(python_logger); + + elsif run("Test that a std_logic_vector length mismatch in a procedure result fails") then + exec("def wrong_length_bits():" + " return '01011'"); + mock(python_logger, failure); + call_std_logic_vector("wrong_length_bits", slv4); + check_only_log( + python_logger, + "eval(""wrong_length_bits()"") failed:" & LF & + "ValueError: Got 5 std_logic values but the VHDL result has length 4", + failure + ); + unmock(python_logger); + + elsif run("Test that an undefined function fails") then + define_error_helper; + mock(python_logger, failure); + discard_int := call("no_such_function"); + check_only_log( + python_logger, + "eval(""no_such_function()"") failed:" & LF & + call_string( + "expected_error", arg(string'("no_such_function()")), arg(string'("")), arg(true) + ), + failure + ); + unmock(python_logger); + + --------------------------------------------------------------------- + -- Argument values + --------------------------------------------------------------------- + elsif run("Test arg and kwarg of real_vector and integer_vector_ptr values") then + define_describe; + -- An aggregate needs a qualified expression to select the overload + check_equal(call_string("describe", arg(real_vector'(1.5, -0.25))), "[1.5, -0.25]"); + check_equal(call_string("describe", kwarg("v", real_vector'(0 => 0.5))), "v=[0.5]"); + ptr := new_integer_vector_ptr(3); + for idx in 0 to 2 loop + set(ptr, idx, idx - 1); + end loop; + check_equal(call_string("describe", arg(ptr)), "[-1, 0, 1]"); + check_equal(call_string("describe", kwarg("v", ptr)), "v=[-1, 0, 1]"); + + elsif run("Test std_logic_vector, signed and unsigned arguments") then + -- There are no arg overloads for these types, which would make a + -- string literal argument ambiguous. They are passed as a string, as + -- an integer or through arg_unsigned/arg_signed instead. + define_describe; + check_equal(call_string("describe", arg(to_string(slv9_desc))), "'UX01ZWLH-'"); + check_equal(call_string("describe", kwarg("v", to_string(std_logic_vector'("10XZ")))), "v='10XZ'"); + s8 := to_signed(-128, 8); + u8 := to_unsigned(255, 8); + check_equal(call_string("describe", arg(to_integer(s8)), arg(to_integer(u8))), "-128, 255"); + exec("def identity(x):" + " return x"); + check_equal(integer'(call("identity", arg(to_integer(s8)))), -128); + check_equal(integer'(call("identity", arg(to_integer(u8)))), 255); + + elsif run("Test arg and kwarg of integer_array_t values") then + exec("def total(*args, **kwargs):" + " return int(sum(a.sum() for a in list(args) + list(kwargs.values())))"); + arr := new_1d(length => 4, bit_width => 16, is_signed => true); + for idx in 0 to 3 loop + set(arr, idx, idx + 1); + end loop; + arr_b := new_1d(length => 2, bit_width => 8, is_signed => false); + set(arr_b, 0, 10); + set(arr_b, 1, 20); + check_equal(integer'(call("total", arg(arr), kwarg("data", arr_b))), 40); + + elsif run("Test positional and keyword arguments together") then + exec("def scale(x, gain=1, offset=0):" + " return x * gain + offset"); + check_equal(integer'(call("scale", arg(5), kwarg("gain", 3), kwarg("offset", 1))), 16); + check_equal(integer'(call("scale", arg(5), kwarg("offset", 2))), 7); + check_equal(integer'(call("scale", arg(5))), 5); + + elsif run("Test that a staged array can be used in several calls") then + exec("def bump(data):" + " data += 1" + " return int(data.sum())"); + arr := new_1d(2); + set(arr, 0, 1); + set(arr, 1, 2); + bump_twice(arg(arr)); + + --------------------------------------------------------------------- + -- Keyword argument groups + --------------------------------------------------------------------- + elsif run("Test a keyword argument group") then + define_describe; + -- The group is a single argument and can be in any of the 10 slots + check_equal(call_string("describe", kwarg("a", 1) & kwarg("b", 2)), "a=1, b=2"); + check_equal( + call_string("describe", arg(1), arg(string'("x")), kwarg("a", 1) & kwarg("b", 2) & kwarg("c", 3)), + "1, 'x', a=1, b=2, c=3" + ); + check_equal( + call_string("describe", (kwarg("a", 1) & kwarg("b", 2)) & (kwarg("c", 3) & kwarg("d", 4))), + "a=1, b=2, c=3, d=4" + ); + + elsif run("Test 25 keyword arguments in one group") then + exec("def total(**kwargs):" + " return sum(kwargs.values())"); + check_equal( + integer'(call( + "total", + kwarg("a01", 1) & kwarg("a02", 2) & kwarg("a03", 3) & kwarg("a04", 4) & kwarg("a05", 5) & + kwarg("a06", 6) & kwarg("a07", 7) & kwarg("a08", 8) & kwarg("a09", 9) & kwarg("a10", 10) & + kwarg("a11", 11) & kwarg("a12", 12) & kwarg("a13", 13) & kwarg("a14", 14) & kwarg("a15", 15) & + kwarg("a16", 16) & kwarg("a17", 17) & kwarg("a18", 18) & kwarg("a19", 19) & kwarg("a20", 20) & + kwarg("a21", 21) & kwarg("a22", 22) & kwarg("a23", 23) & kwarg("a24", 24) & kwarg("a25", 25) + )), + 325 + ); + + elsif run("Test that null_arg is the identity of a keyword argument group") then + define_describe; + check_equal(call_string("describe", null_arg & kwarg("a", 1)), "a=1"); + check_equal(call_string("describe", kwarg("a", 1) & null_arg), "a=1"); + check_equal(call_string("describe", null_arg & null_arg), ""); + check_equal(call_string("describe", null_arg & (kwarg("a", 1) & kwarg("b", 2)) & null_arg), "a=1, b=2"); + + elsif run("Test a keyword argument group with the result types of call") then + define_pick; + check_equal(integer'(call("pick", kwarg("v", 7) & kwarg("w", 0))), 7); + check_equal(real'(call("pick", kwarg("v", 1.5) & kwarg("w", 0))), 1.5); + check_equal(call_string("pick", kwarg("v", string'("x")) & kwarg("w", 0)), "x"); + check_true(call_boolean("pick", kwarg("v", true) & kwarg("w", 0))); + check( + integer_vector'(call("pick", kwarg("v", integer_vector'(1, 2)) & kwarg("w", 0))) = integer_vector'(1, 2) + ); + + exec("recorded = 0" + "def record(**kwargs):" + " global recorded" + " recorded = kwargs['v']"); + call("record", kwarg("v", 42) & kwarg("w", 0)); + check_equal(eval_integer("recorded"), 42); + + elsif run("Test that a repeated keyword in a group is a Python error") then + define_error_helper; + mock(python_logger, failure); + check_equal(call_string("describe", kwarg("a", 1) & kwarg("a", 2)), ""); + check_only_log( + python_logger, + "eval(""describe(**dict(a=1, a=2))"") failed:" & LF & + call_string( + "expected_error", arg(string'("describe(**dict(a=1, a=2))")), arg(string'("")), arg(true) + ), + failure + ); + unmock(python_logger); + + elsif run("Test that a keyword argument group before a positional argument is a Python error") then + define_error_helper; + mock(python_logger, failure); + check_equal(call_string("describe", kwarg("a", 1) & kwarg("b", 2), arg(3)), ""); + check_only_log( + python_logger, + "eval(""describe(**dict(a=1, b=2), 3)"") failed:" & LF & + call_string( + "expected_error", arg(string'("describe(**dict(a=1, b=2), 3)")), arg(string'("")), arg(true) + ), + failure + ); + unmock(python_logger); + + elsif run("Test that combining a positional argument with & fails") then + define_describe; + mock(python_logger, failure); + check_equal(call_string("describe", arg(1) & kwarg("a", 2)), ""); + check_log(python_logger, group_error, failure); + check_equal(call_string("describe", kwarg("a", 2) & arg(1)), ""); + check_log(python_logger, group_error, failure); + check_equal(call_string("describe", (kwarg("a", 1) & kwarg("b", 2)) & arg(3)), ""); + check_only_log(python_logger, group_error, failure); + unmock(python_logger); + + --------------------------------------------------------------------- + -- Wide and std_logic argument values + --------------------------------------------------------------------- + elsif run("Test unsigned arguments of any width") then + define_as_str; + check_equal(call_string("as_str", arg_unsigned(u1)), "1"); + check_equal(call_string("as_str", arg_unsigned(unsigned'("0"))), "0"); + check_equal(call_string("as_str", arg_unsigned(unsigned'(x"FF"))), "255"); + check_equal(call_string("as_str", arg_unsigned(u32)), "3735928559"); + check_equal(call_string("as_str", arg_unsigned(u64)), "18446744073709551615"); + check_equal( + call_string("as_str", arg_unsigned(u128)), "340282366920938463463374607431768211455" + ); + check_equal(call_string("as_str", arg_unsigned(u_null)), "0"); + + -- The value survives a round trip through Python + exec("def identity(x):" + " return x"); + call_unsigned("identity", u32, arg_unsigned(u32)); + check_equal(u32, unsigned'(x"DEADBEEF")); + + elsif run("Test signed arguments including the lowest value") then + define_as_str; + check_equal(call_string("as_str", arg_signed(signed'(x"7F"))), "127"); + check_equal(call_string("as_str", arg_signed(signed'("0"))), "0"); + check_equal(call_string("as_str", arg_signed(signed'("1"))), "-1"); + check_equal(call_string("as_str", arg_signed(to_signed(-1, 8))), "-1"); + check_equal(call_string("as_str", arg_signed(to_signed(-128, 8))), "-128"); + check_equal(call_string("as_str", arg_signed(s64)), "-9223372036854775808"); + check_equal(call_string("as_str", arg_signed(s_null)), "0"); + + exec("def identity(x):" + " return x"); + call_signed("identity", s8, arg_signed(to_signed(-128, 8))); + check_equal(s8, to_signed(-128, 8)); + + elsif run("Test that H and L are read as 1 and 0 in argument values") then + define_as_str; + check_equal(call_string("as_str", arg_unsigned(unsigned'("HLHLHLHL"))), "170"); + check_equal(call_string("as_str", arg_signed(signed'("HLHL"))), "-6"); + define_describe; + check_equal(call_string("describe", arg('H'), arg('L')), "True, False"); + + elsif run("Test that a metavalue in an unsigned or signed argument fails") then + define_describe; + mock(python_logger, failure); + check_equal(call_string("describe", arg_unsigned(unsigned'("1010X010"))), ""); + check_log( + python_logger, "arg_unsigned cannot convert ""1010X010""; the value has metavalues", failure + ); + check_equal(call_string("describe", arg_signed(signed'("10Z0"))), ""); + check_log(python_logger, "arg_signed cannot convert ""10Z0""; the value has metavalues", failure); + check_equal(call_string("describe", kwarg_unsigned("v", unsigned'("U"))), ""); + check_only_log( + python_logger, "kwarg_unsigned cannot convert ""U""; the value has metavalues", failure + ); + unmock(python_logger); + + elsif run("Test std_logic arguments") then + define_describe; + check_equal(call_string("describe", arg('1'), arg('0')), "True, False"); + check_equal(call_string("describe", kwarg("v", '1'), kwarg("w", 'L')), "v=True, w=False"); + + elsif run("Test that a metavalue std_logic argument fails") then + define_describe; + mock(python_logger, failure); + check_equal(call_string("describe", arg('X')), ""); + check_log(python_logger, "arg cannot convert 'X'; expected '0', '1', 'L' or 'H'", failure); + check_equal(call_string("describe", kwarg("v", '-')), ""); + check_only_log(python_logger, "kwarg cannot convert '-'; expected '0', '1', 'L' or 'H'", failure); + unmock(python_logger); + + elsif run("Test keyword forms of the typed argument values") then + define_describe; + check_equal(call_string("describe", kwarg_unsigned("v", unsigned'(x"FF"))), "v=255"); + check_equal(call_string("describe", kwarg_signed("v", to_signed(-3, 4))), "v=-3"); + check_equal( + call_string("describe", kwarg_unsigned("v", u64) & kwarg_signed("w", s64)), + "v=18446744073709551615, w=-9223372036854775808" + ); + + --------------------------------------------------------------------- + -- integer_array_t + --------------------------------------------------------------------- + elsif run("Test that a 2D array preserves axis orientation, get(a, x, y) is row y column x") then + arr := new_2d(width => 3, height => 2, bit_width => 16, is_signed => false); + for y in 0 to 1 loop + for x in 0 to 2 loop + set(arr, x, y, y * 10 + x); + end loop; + end loop; + exec( + "def orientation_ok(a):" + + " return bool(a[0, 1] == 1 and a[1, 2] == 12 and a.shape == (2, 3))" + ); + check_true(call_boolean("orientation_ok", arg(arr))); + + elsif run("Test that a 3D array preserves axis orientation, get(a, x, y, z) is row y column x plane z") then + arr := new_3d(width => 3, height => 2, depth => 4, bit_width => 16, is_signed => false); + for y in 0 to 1 loop + for x in 0 to 2 loop + for z in 0 to 3 loop + set(arr, x, y, z, y * 100 + x * 10 + z); + end loop; + end loop; + end loop; + exec( + "def orientation_ok3(a):" + + " return bool(a[0, 1, 2] == 12 and a[1, 2, 3] == 123 and a.shape == (2, 3, 4))" + ); + check_true(call_boolean("orientation_ok3", arg(arr))); + + elsif run("Test that returned 2D and 3D arrays preserve axis orientation") then + exec( + "import numpy as np" + + "def make2():" + + " return np.array([[10 * y + x for x in range(3)] for y in range(2)], dtype=np.int16)" + + "def make3():" + + " return np.fromfunction(lambda y, x, z: 100 * y + 10 * x + z, (2, 3, 4), dtype=int)" + ); + result := call_integer_array("make2"); + check_equal(width(result), 3); + check_equal(height(result), 2); + check_equal(depth(result), 1); + check_equal(bit_width(result), 16); + check_true(is_signed(result)); + for y in 0 to 1 loop + for x in 0 to 2 loop + check_equal(get(result, x, y), 10 * y + x); + end loop; + end loop; + + result := call_integer_array("make3"); + check_equal(width(result), 3); + check_equal(height(result), 2); + check_equal(depth(result), 4); + for y in 0 to 1 loop + for x in 0 to 2 loop + for z in 0 to 3 loop + check_equal(get(result, x, y, z), 100 * y + 10 * x + z); + end loop; + end loop; + end loop; + + elsif run("Test that metadata is preserved when returning the same array argument") then + arr := new_1d(length => 4, bit_width => 12, is_signed => true); + set(arr, 0, -2048); + set(arr, 1, -1); + set(arr, 2, 0); + set(arr, 3, 2047); + exec("def identity(a):" + " return a"); + result := call_integer_array("identity", arg(arr)); + check_equal(bit_width(result), 12); + check_true(is_signed(result)); + check_equal(length(result), 4); + for idx in 0 to 3 loop + check_equal(get(result, idx), get(arr, idx)); + end loop; + + elsif run("Test that metadata is derived from dtype for a newly created array") then + arr := new_1d(length => 4, bit_width => 10, is_signed => false); + set(arr, 0, 0); + set(arr, 1, 1); + set(arr, 2, 500); + set(arr, 3, 1023); + exec( + "import numpy as np" + + "def to_uint8(a):" + + " return (a % 256).astype(np.uint8)" + ); + result := call_integer_array("to_uint8", arg(arr)); + check_equal(bit_width(result), 8); + check_false(is_signed(result)); + check_equal(length(result), 4); + check_equal(get(result, 0), 0); + check_equal(get(result, 1), 1); + check_equal(get(result, 2), 500 mod 256); + check_equal(get(result, 3), 1023 mod 256); + + elsif run("Test call with several array arguments") then + exec( + "def sum_arrays(*args):" + + " return int(sum(int(a.sum()) for a in args))" + ); + check_equal(integer'(call("sum_arrays")), 0); + arr := new_1d(length => 3, bit_width => 8, is_signed => false); + set(arr, 0, 1); + set(arr, 1, 2); + set(arr, 2, 3); + check_equal(integer'(call("sum_arrays", arg(arr))), 6); + arr_b := new_1d(length => 2, bit_width => 8, is_signed => false); + set(arr_b, 0, 10); + set(arr_b, 1, 20); + check_equal(integer'(call("sum_arrays", arg(arr), arg(arr_b), arg(arr))), 42); + + elsif run("Test returning a new array with a different shape") then + arr := new_2d(width => 3, height => 2, bit_width => 16, is_signed => false); + for y in 0 to 1 loop + for x in 0 to 2 loop + set(arr, x, y, y * 10 + x); + end loop; + end loop; + exec("def flatten(a):" + " return a.reshape(-1)"); + result := call_integer_array("flatten", arg(arr)); + check_equal(length(result), 6); + check_equal(bit_width(result), 32); + check_true(is_signed(result)); + check_equal(get(result, 0), 0); + check_equal(get(result, 1), 1); + check_equal(get(result, 2), 2); + check_equal(get(result, 3), 10); + check_equal(get(result, 4), 11); + check_equal(get(result, 5), 12); + + elsif run("Test null array round trip") then + arr := new_1d(length => 0, bit_width => 16, is_signed => false); + exec("def identity(a):" + " return a"); + result := call_integer_array("identity", arg(arr)); + check_equal(length(result), 0); + + --------------------------------------------------------------------- + -- Sessions + --------------------------------------------------------------------- + elsif run("Test that sessions have separate namespaces") then + exec("x = 1" + "def get_x(): return x", golden); + exec("x = 2" + "def get_x(): return x", fixed_point); + exec("x = 3" + "def get_x(): return x"); + check_equal(integer'(call("get_x", session => golden)), 1); + check_equal(integer'(call("get_x", session => fixed_point)), 2); + check_equal(integer'(call("get_x")), 3); + check_equal(integer'(call("get_x", session => default_session)), 3); + check_equal(eval_integer("x", golden), 1); + + elsif run("Test that a name defined in another session is not visible") then + define_error_helper; + exec("def only_in_golden(): return 1", golden); + mock(python_logger, failure); + discard_int := call("only_in_golden", session => fixed_point); + check_only_log( + python_logger, + "eval(""only_in_golden()"", session => ""fixed_point"") failed:" & LF & + call_string( + "expected_error", arg(string'("only_in_golden()")), arg(string'("")), arg(true) + ), + failure + ); + unmock(python_logger); + + elsif run("Test executing a file in different sessions") then + exec_file("test/models/counter.py", golden); + exec_file("test/models/counter.py", golden); + exec_file("test/models/counter.py", fixed_point); + check_equal(integer'(call("get_call_count", session => golden)), 2); + check_equal(integer'(call("get_call_count", session => fixed_point)), 1); + + elsif run("Test arrays and procedure results in a session") then + exec("def double(a): return a * 2", golden); + arr := new_1d(3); + for idx in 0 to 2 loop + set(arr, idx, idx + 1); + end loop; + result := call_integer_array("double", arg(arr), session => golden); + check_equal(get(result, 2), 6); + + exec("def identity(x): return x", golden); + call_unsigned("identity", u8, arg(42), session => golden); + check_equal(to_integer(u8), 42); + + elsif run("Test that imported modules are shared between sessions") then + exec("import math" + "math.vunit_marker = 7", golden); + exec("import math" + "def get_marker(): return math.vunit_marker", fixed_point); + check_equal(integer'(call("get_marker", session => fixed_point)), 7); + + elsif run("Test error context names the session") then + define_error_helper; + mock(python_logger, failure); + exec("1 / 0", golden); + check_only_log( + python_logger, + "exec(session => ""golden"") failed:" & LF & + call_string("expected_error", arg(string'("1 / 0")), arg(string'(""))), + failure + ); + unmock(python_logger); + + end if; + end loop; + + test_runner_cleanup(runner); + end process; +end architecture; diff --git a/vunit/vhdl/python/tools/generate_python_pkg.py b/vunit/vhdl/python/tools/generate_python_pkg.py new file mode 100644 index 000000000..d13e96fe7 --- /dev/null +++ b/vunit/vhdl/python/tools/generate_python_pkg.py @@ -0,0 +1,392 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Generate python_pkg.vhd from python_pkg.vhd.in in this directory. + +The template holds the parts of python_pkg that are written by hand. The +generated parts are the argument value types of call, the result types of eval +and call and the execution of Python files. They are mostly one overload per +type, which is why they are generated. The operations that are implemented by +the Python bridge (NVC, GHDL and Questa) are built on the p_ prefixed +primitives of python_ffi_pkg, which the VHPI variant of that package +implements by reporting a failure. +""" + +from pathlib import Path +from string import Template + +SRC_PATH = Path(__file__).parent.parent / "src" +TEMPLATE_PATH = Path(__file__).parent + +# Like the operations of python_pkg, every operation takes the session it is +# performed in as its last parameter +SESSION = "session : python_session_t := default_session" + +# The 10 arguments of call +ARGS = [f"arg{index}" for index in range(1, 11)] +ARG_PARAMETERS = f"{', '.join(ARGS)} : arg_t := null_arg" +ARG_ACTUALS = ", ".join(ARGS) +ARG_SIGNATURE = ", ".join(["string"] + ["arg_t"] * len(ARGS) + ["python_session_t"]) + +# Value types of arg and kwarg that are generated rather than declared in the +# template. +# +# vhdl: the VHDL type +# impure: true when the conversion can fail, which makes the functions impure +# suffix: appended to the arg and kwarg names, empty for an overload +# bridge: true when the value is transferred by the Python bridge, which makes +# it available for NVC, GHDL and Questa only. The other values are +# built from the Python source text of the value alone and work on any +# simulator. +# +# The unsigned and signed values are passed to typed names, arg_unsigned and +# arg_signed, on purpose: plain overloads would make a string literal +# argument, arg("Hello"), ambiguous since a string literal belongs to every +# character array type. std_logic needs no such name, being a scalar, and +# std_logic_vector is deliberately not among the types at all. Such values +# are passed as arg(to_string(slv)) and arg_unsigned(unsigned(slv)). +ARG_VALUES = [ + dict(vhdl="real_vector", impure=False, suffix="", bridge=False), + dict(vhdl="integer_vector_ptr_t", impure=True, suffix="", bridge=False), + dict(vhdl="integer_array_t", impure=True, suffix="", bridge=True), + dict(vhdl="std_logic", impure=True, suffix="", bridge=False), + dict(vhdl="unsigned", impure=True, suffix="_unsigned", bridge=False), + dict(vhdl="signed", impure=True, suffix="_signed", bridge=False), +] + +# Result types of eval and call. +# +# name: suffix of eval_ and call_ +# vhdl: the VHDL type +# kind: result kind passed to the bridge, see vunit/python_bridge/runtime.py +# value: expression giving the result of a successful evaluation +# default: value returned after a failure +# hand_written_eval/hand_written_call: true when the eval/call function is +# declared in the template rather than generated. value and default +# are only needed when the eval function is generated. +RESULTS = [ + dict( + name="integer", + vhdl="integer", + kind="p_kind_integer", + hand_written_eval=True, + hand_written_call=True, + ), + dict( + name="real", + vhdl="real", + kind="p_kind_real", + hand_written_eval=True, + hand_written_call=True, + ), + dict( + name="integer_vector", + vhdl="integer_vector", + kind="p_kind_integer_vector", + hand_written_eval=True, + hand_written_call=True, + ), + dict( + name="real_vector", + vhdl="real_vector", + kind="p_kind_real_vector", + hand_written_eval=True, + hand_written_call=False, + ), + dict( + name="string", + vhdl="string", + kind="p_kind_string", + hand_written_eval=True, + hand_written_call=False, + ), + dict( + name="integer_vector_ptr", + vhdl="integer_vector_ptr_t", + kind="p_kind_integer_vector", + hand_written_eval=True, + hand_written_call=False, + ), + dict( + name="boolean", + vhdl="boolean", + kind="p_kind_boolean", + value="p_result_integer /= 0", + default="false", + hand_written_eval=False, + hand_written_call=False, + ), + dict( + name="std_logic", + vhdl="std_logic", + kind="p_kind_std_logic", + value="p_to_std_logic(p_result_string(1))", + default="'U'", + hand_written_eval=False, + hand_written_call=False, + ), + dict( + name="std_logic_vector", + vhdl="std_logic_vector", + kind="p_kind_std_logic_vector", + value="p_to_std_logic_vector(p_result_string)", + default='""', + hand_written_eval=False, + hand_written_call=False, + # No eval/call alias: check_equal(eval("17"), 17) would otherwise be ambiguous + # with check_equal(std_logic_vector, natural). Use the explicit names. + alias_eval=False, + alias_call=False, + ), + dict( + name="integer_array", + vhdl="integer_array_t", + kind="p_kind_integer_array", + value="p_result_integer_array", + default="null_integer_array", + hand_written_eval=False, + hand_written_call=False, + # No eval alias: length(eval(...)) would otherwise be ambiguous between + # the integer_array_t and integer_vector_ptr_t results. Use eval_integer_array. + alias_eval=False, + ), +] + +# Result types whose width is given by the actual of the result parameter +PROCEDURE_RESULTS = [ + dict( + name="std_logic_vector", + vhdl="std_logic_vector", + kind="p_kind_std_logic_vector", + value="p_to_std_logic_vector(p_result_string)", + ), + dict( + name="signed", + vhdl="signed", + kind="p_kind_signed", + value="signed(p_to_std_logic_vector(p_result_string))", + ), + dict( + name="unsigned", + vhdl="unsigned", + kind="p_kind_unsigned", + value="unsigned(p_to_std_logic_vector(p_result_string))", + ), +] + + +def call_name(result): + """ + Name of the call function of a result type. The integer one is named + call_integer_w_arg by python_pkg. + """ + return "call_integer_w_arg" if result["name"] == "integer" else f"call_{result['name']}" + + +# ------------------------------------------------------------------------- +# arg and kwarg +# ------------------------------------------------------------------------- +def arg_declarations(bridge): + """ + Declarations of the generated arg and kwarg subprograms. + """ + lines = [] + for value in ARG_VALUES: + if value["bridge"] != bridge: + continue + purity = "impure " if value["impure"] else "" + suffix = value["suffix"] + lines.append(f" {purity}function arg{suffix}(value : {value['vhdl']}) return arg_t;") + lines.append(f" {purity}function kwarg{suffix}(kw : string; value : {value['vhdl']}) return arg_t;") + return "\n".join(lines) + + +def arg_functions(bridge): + """ + Bodies of the generated arg and kwarg subprograms. p_arg_value returns an + empty string when the value cannot be converted, having reported it. + """ + parts = [] + for value in ARG_VALUES: + if value["bridge"] != bridge: + continue + purity = "impure " if value["impure"] else "" + value_type = value["vhdl"] + suffix = value["suffix"] + for name, parameters, result in [ + (f"arg{suffix}", f"value : {value_type}", "(p_positional_arg, text)"), + (f"kwarg{suffix}", f"kw : string; value : {value_type}", "(kw, text)"), + ]: + parts.append( + f"""\ + {purity}function {name}({parameters}) return arg_t is + constant text : string := p_arg_value(value, "{name}"); + begin + if text = "" then + return null_arg; + end if; + return {result}; + end; +""" + ) + return "\n".join(parts).rstrip("\n") + + +# ------------------------------------------------------------------------- +# eval +# ------------------------------------------------------------------------- +def eval_declarations(): + """ + Declarations of the generated eval functions and of the eval procedures. + """ + lines = [] + for result in RESULTS: + if result["hand_written_eval"]: + continue + lines.append(f" impure function eval_{result['name']}(") + lines.append(f" expr : string; {SESSION}") + lines.append(f" ) return {result['vhdl']};") + if result.get("alias_eval", True): + lines.append(f" alias eval is eval_{result['name']}[string, python_session_t return {result['vhdl']}];") + lines.append("") + for result in PROCEDURE_RESULTS: + lines.append(f" procedure eval_{result['name']}(") + lines.append(f" expr : string; result : out {result['vhdl']}; {SESSION}") + lines.append(" );") + return "\n".join(lines) + + +def eval_subprograms(): + """ + Bodies of the generated eval functions and of the eval procedures. + """ + parts = [] + for result in RESULTS: + if result["hand_written_eval"]: + continue + parts.append( + f"""\ + impure function eval_{result['name']}( + expr : string; {SESSION} + ) return {result['vhdl']} is + begin + if p_eval(expr, {result['kind']}, -1, p_eval_operation(expr, session), session) then + return {result['value']}; + end if; + return {result['default']}; + end; +""" + ) + for result in PROCEDURE_RESULTS: + parts.append( + f"""\ + procedure eval_{result['name']}( + expr : string; result : out {result['vhdl']}; {SESSION} + ) is + begin + if p_eval(expr, {result['kind']}, result'length, p_eval_operation(expr, session), session) then + result := {result['value']}; + end if; + end; +""" + ) + return "\n".join(parts).rstrip("\n") + + +# ------------------------------------------------------------------------- +# call +# ------------------------------------------------------------------------- +def call_declarations(): + """ + Declarations of the generated call functions and of the call procedures. + """ + lines = [] + for result in RESULTS: + if result["hand_written_call"]: + continue + name = call_name(result) + lines.append(f" impure function {name}(") + lines.append(f" identifier : string; {ARG_PARAMETERS};") + lines.append(f" {SESSION}") + lines.append(f" ) return {result['vhdl']};") + if result.get("alias_call", True): + lines.append(f" alias call is {name}[") + lines.append(f" {ARG_SIGNATURE} return {result['vhdl']}];") + lines.append("") + for result in PROCEDURE_RESULTS: + lines.append(f" procedure call_{result['name']}(") + lines.append(f" identifier : string; result : out {result['vhdl']};") + lines.append(f" {ARG_PARAMETERS};") + lines.append(f" {SESSION}") + lines.append(" );") + return "\n".join(lines) + + +def call_subprograms(): + """ + Bodies of the generated call functions and of the call procedures. A call + is the evaluation of the Python expression calling the identifier with the + given arguments. + """ + parts = [] + for result in RESULTS: + if result["hand_written_call"]: + continue + parts.append( + f"""\ + impure function {call_name(result)}( + identifier : string; {ARG_PARAMETERS}; + {SESSION} + ) return {result['vhdl']} is + begin + return eval_{result['name']}( + p_to_call_str(identifier, {ARG_ACTUALS}), session + ); + end; +""" + ) + for result in PROCEDURE_RESULTS: + parts.append( + f"""\ + procedure call_{result['name']}( + identifier : string; result : out {result['vhdl']}; + {ARG_PARAMETERS}; + {SESSION} + ) is + begin + eval_{result['name']}( + p_to_call_str(identifier, {ARG_ACTUALS}), result, session + ); + end; +""" + ) + return "\n".join(parts).rstrip("\n") + + +def generate_package(): + """ + Generate python_pkg from the template and the generated declarations. + """ + template = (TEMPLATE_PATH / "python_pkg.vhd.in").read_text(encoding="utf-8") + return Template(template).substitute( + value_arg_declarations=arg_declarations(bridge=False), + value_arg_functions=arg_functions(bridge=False), + arg_declarations=arg_declarations(bridge=True), + eval_declarations=eval_declarations(), + call_declarations=call_declarations(), + arg_functions=arg_functions(bridge=True), + eval_subprograms=eval_subprograms(), + call_subprograms=call_subprograms(), + ) + + +def main(): + (SRC_PATH / "python_pkg.vhd").write_text(generate_package(), encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + main() diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in new file mode 100644 index 000000000..efa25e9df --- /dev/null +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -0,0 +1,697 @@ +-- This package provides a dictionary types and operations +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +use work.python_ffi_pkg.all; +use work.path.all; +use work.run_pkg.all; +use work.runner_pkg.all; +use work.integer_vector_ptr_pkg.all; +use work.string_ops.all; + +use std.textio.all; + +------------------------------------------------------------------------------ +-- This file is generated from tools/python_pkg.vhd.in by +-- vunit/vhdl/python/tools/generate_python_pkg.py. Do not edit. +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +use work.integer_array_pkg.all; +use work.logger_pkg.all; +------------------------------------------------------------------------------ + +package python_pkg is + procedure import_module_from_file( + module_path, as_module_name : string; session : python_session_t := default_session + ); + procedure import_run_script(module_name : string := ""; session : python_session_t := default_session); + + function to_py_list_str(vec : integer_vector) return string; + impure function to_py_list_str(vec : integer_vector_ptr_t) return string; + function to_py_list_str(vec : real_vector) return string; + + function "+"(l, r : string) return string; + + impure function eval_integer_vector_ptr( + expr : string; session : python_session_t := default_session + ) return integer_vector_ptr_t; + alias eval is eval_integer_vector_ptr[string, python_session_t return integer_vector_ptr_t]; + + function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" + ) return string; + + type arg_t is record + name : string; + value : string; + end record; + constant p_positional_arg : string := "."; + constant p_ignore_arg : string := "-"; + constant null_arg : arg_t := (name => p_ignore_arg, value => ""); + + function arg(value : integer) return arg_t; + function kwarg(kw : string; value : integer) return arg_t; + function arg(value : string) return arg_t; + function kwarg(kw : string; value : string) return arg_t; + function arg(value : integer_vector) return arg_t; + function kwarg(kw : string; value : integer_vector) return arg_t; + function arg(value : real) return arg_t; + function kwarg(kw : string; value : real) return arg_t; + function arg(value : boolean) return arg_t; + function kwarg(kw : string; value : boolean) return arg_t; + + impure function call_integer_w_arg( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer; + alias call is call_integer_w_arg[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return integer]; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ); + + impure function call_integer_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector; + alias call is call_integer_vector[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t + return integer_vector]; + + impure function call_real( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real; + alias call is call_real[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real]; + + ----------------------------------------------------------------------------- + -- More argument values of call + ----------------------------------------------------------------------------- + -- real_vector and integer_vector_ptr_t values become Python lists, a + -- std_logic value becomes True or False, and an unsigned or signed value of + -- any width becomes a Python integer written as a hexadecimal literal, which + -- is therefore not limited to the range of a VHDL integer. + -- + -- An aggregate or a literal needs a qualified expression to select the + -- overload, for example arg(real_vector'(1.0, 2.0)). A std_logic_vector is + -- passed as a string, arg(to_string(slv)), or as a number, + -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of + -- their own since an overload would make a string literal argument, + -- arg("Hello"), ambiguous. + -- + -- H and L are read as 1 and 0. Any other metavalue is an error. +$value_arg_declarations + + ----------------------------------------------------------------------------- + -- Keyword argument groups + ----------------------------------------------------------------------------- + -- Keyword arguments combined with & become a single argument, so that a call + -- can pass more than 10 keyword arguments and can build its keyword + -- arguments in steps: + -- + -- call("f", arg(x), kwarg("a", 1) & kwarg("b", 2)); + -- + -- calls f(x, **dict(a=1, b=2)). null_arg is the identity of the operation + -- and only keyword arguments, or groups of them, can be combined. Python + -- keeps its own rules: a repeated keyword and a group followed by a + -- positional argument are syntax errors. + impure function "&"(l, r : arg_t) return arg_t; + + ----------------------------------------------------------------------------- + -- Operations implemented by the Python bridge (NVC, GHDL and Questa) + ----------------------------------------------------------------------------- + -- The operations below are implemented by the VUnit Python bridge and are + -- therefore only available for NVC, GHDL and Questa; the other simulators + -- report a failure when they are used. Like the other operations, every one + -- of them takes the session it is performed in as its last parameter, + -- defaulting to the default session. + + ----------------------------------------------------------------------------- + -- Argument values of call: integer arrays + ----------------------------------------------------------------------------- + -- An integer_array_t value is transferred to Python and referred to by the + -- expression, which means that it can be used in several calls. +$arg_declarations + + ----------------------------------------------------------------------------- + -- Results of eval: boolean, std_logic, vectors and arrays + ----------------------------------------------------------------------------- + -- std_logic_vector and integer_array_t results are only available under + -- their explicit names, not as eval overloads, which keeps + -- check_equal(eval("17"), 17) and length(eval("[1, 2]")) unambiguous. + -- signed and unsigned results are only available in the procedure form + -- since a function cannot know the width of the result. +$eval_declarations + + ----------------------------------------------------------------------------- + -- Results of call: string, boolean, std_logic, vectors and arrays + ----------------------------------------------------------------------------- +$call_declarations + + ----------------------------------------------------------------------------- + -- Execution of Python files + ----------------------------------------------------------------------------- + -- Execute a Python file. A relative file name is relative to the directory + -- of the run script. The file is executed with __file__ set and its own + -- directory on sys.path so that it can import its siblings. + procedure exec_file(file_name : string; session : python_session_t := default_session); +end package; + +package body python_pkg is + -- @formatter:off + procedure import_module_from_file( + module_path, as_module_name : string; session : python_session_t := default_session + ) is + constant spec_name : string := "__" & as_module_name & "_spec"; + constant code : string := + "from importlib.util import spec_from_file_location, module_from_spec" & LF & + "from pathlib import Path" & LF & + "import sys" & LF & + spec_name & " = spec_from_file_location('" & as_module_name & "', str(Path('" & module_path & "')))" & LF & + as_module_name & " = module_from_spec(" & spec_name & ")" & LF & + "sys.modules['" & as_module_name & "'] = " & as_module_name & LF & + spec_name & ".loader.exec_module(" & as_module_name & ")"; + begin + exec(code, session); + end; + -- @formatter:on + + -- Importing a run script runs its module level code, so one without an + -- if __name__ == "__main__" guard would start VUnit again + procedure p_check_run_script_guard(script_path : string; session : python_session_t) is + begin + exec( + "from pathlib import Path" & LF & + "if '__main__' not in Path('" & script_path & "').read_text():" & LF & + " raise RuntimeError('The run script " & script_path & " has no if __name__ == ""__main__"" guard, " & + "importing it would run VUnit again')", + session); + end; + + procedure import_run_script(module_name : string := ""; session : python_session_t := default_session) is + constant script_path : string := run_script_path(get_cfg(runner_state)); + variable path_items : lines_t; + variable script_name : line; + begin + p_check_run_script_guard(script_path, session); + if module_name = "" then + -- Extract the last item in the full path + path_items := split(script_path, "/"); + for idx in path_items'range loop + if idx = path_items'right then + script_name := path_items(idx); + else + deallocate(path_items(idx)); + end if; + end loop; + deallocate(path_items); + + -- Set module name to script name minus its extension + path_items := split(script_name.all, "."); + deallocate(script_name); + for idx in path_items'range loop + if idx = path_items'left then + import_module_from_file(script_path, path_items(idx).all, session); + end if; + deallocate(path_items(idx)); + end loop; + deallocate(path_items); + else + import_module_from_file(script_path, module_name, session); + end if; + end; + + function to_py_list_str(vec : integer_vector) return string is + variable l : line; + begin + swrite(l, "["); + for idx in vec'range loop + swrite(l, to_string(vec(idx))); + if idx /= vec'right then + swrite(l, ","); + end if; + end loop; + swrite(l, "]"); + + return l.all; + end; + + impure function to_py_list_str(vec : integer_vector_ptr_t) return string is + variable l : line; + begin + swrite(l, "["); + for idx in 0 to length(vec) - 1 loop + swrite(l, to_string(get(vec, idx))); + if idx /= length(vec) - 1 then + swrite(l, ","); + end if; + end loop; + swrite(l, "]"); + + return l.all; + end; + + function to_py_list_str(vec : real_vector) return string is + variable l : line; + begin + swrite(l, "["); + for idx in vec'range loop + -- Use %.16e to ensure that the string representation of the real number is precise enough to avoid loss of + -- information when double-precision is used. + swrite(l, to_string(vec(idx), "%.16e")); + if idx /= vec'right then + swrite(l, ","); + end if; + end loop; + swrite(l, "]"); + + return l.all; + end; + + function "+"(l, r : string) return string is + begin + return l & LF & r; + end; + + impure function eval_integer_vector_ptr( + expr : string; session : python_session_t := default_session + ) return integer_vector_ptr_t is + constant result_integer_vector : integer_vector := eval(expr, session); + constant len : natural := result_integer_vector'length; + constant result_integer_vector_normalized : integer_vector(0 to len - 1) := result_integer_vector; + constant result : integer_vector_ptr_t := new_integer_vector_ptr(len); + begin + for idx in 0 to len - 1 loop + set(result, idx, result_integer_vector_normalized(idx)); + end loop; + + return result; + end; + + function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" + ) return string is + -- The non-empty arguments, separated by ", " + function append(args, arg : string) return string is + begin + if arg = "" then + return args; + elsif args = "" then + return arg; + else + return args & ", " & arg; + end if; + end; + begin + return identifier & "(" & append(append(append(append(append(append(append(append(append(append( + "", arg1), arg2), arg3), arg4), arg5), arg6), arg7), arg8), arg9), arg10) & ")"; + end; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" + ) is + begin + exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)); + end; + + function arg(value : integer) return arg_t is + begin + return (p_positional_arg, to_string(value)); + end; + + function kwarg(kw : string; value : integer) return arg_t is + begin + return (kw, to_string(value)); + end; + + function arg(value : string) return arg_t is + begin + return (p_positional_arg, '"' & value & '"'); + end; + + function kwarg(kw : string; value : string) return arg_t is + begin + return (kw, '"' & value & '"'); + end; + + function arg(value : integer_vector) return arg_t is + begin + return (p_positional_arg, to_py_list_str(value)); + end; + + function kwarg(kw : string; value : integer_vector) return arg_t is + begin + return (kw, to_py_list_str(value)); + end; + + function arg(value : real) return arg_t is + begin + return (p_positional_arg, to_string(value, "%.16e")); + end; + + function kwarg(kw : string; value : real) return arg_t is + begin + return (kw, to_string(value, "%.16e")); + end; + + function arg(value : boolean) return arg_t is + begin + if value then + return (p_positional_arg, "True"); + else + return (p_positional_arg, "False"); + end if; + end; + + function kwarg(kw : string; value : boolean) return arg_t is + begin + if value then + return (kw, "True"); + else + return (kw, "False"); + end if; + end; + + impure function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t + ) return string is + + function arg_to_str(value : arg_t) return string is + begin + if value.name = p_ignore_arg then + return ""; + elsif value.name = p_positional_arg then + return value.value; + else + return value.name & "=" & value.value; + end if; + end; + + constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; + begin + return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); + end; + + impure function call_integer_w_arg( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_integer_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_real( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + ----------------------------------------------------------------------------- + -- More argument values of call + ----------------------------------------------------------------------------- + -- The Python source text of an argument value. The operation is only used to + -- report a value that cannot be converted, which gives an empty string. + function p_arg_value(value : real_vector; operation : string) return string is + begin + return to_py_list_str(value); + end; + + impure function p_arg_value(value : integer_vector_ptr_t; operation : string) return string is + begin + return to_py_list_str(value); + end; + + impure function p_arg_value(value : std_logic; operation : string) return string is + begin + if to_x01(value) = '1' then + return "True"; + elsif to_x01(value) = '0' then + return "False"; + end if; + failure( + python_logger, + operation & " cannot convert " & std_logic'image(value) & "; expected '0', '1', 'L' or 'H'" + ); + return ""; + end; + + -- True when the vector has an element other than 0, 1, L and H + function p_has_metavalue(value : std_logic_vector) return boolean is + begin + for idx in value'range loop + if to_x01(value(idx)) = 'X' then + return true; + end if; + end loop; + return false; + end; + + -- The Python hexadecimal literal of the magnitude given by the bits, for + -- example 0x1F or -0x80. A null range gives 0x0. + function p_to_hex_literal(bits : std_logic_vector; negative : boolean) return string is + constant hex_digits : string(1 to 16) := "0123456789ABCDEF"; + constant padding : natural := (4 - bits'length mod 4) mod 4; + variable padded : std_logic_vector(bits'length + padding - 1 downto 0) := (others => '0'); + variable result : line; + variable digit : natural; + variable leading : boolean := true; + begin + padded(bits'length - 1 downto 0) := bits; + swrite(result, "0x"); + for nibble in padded'length / 4 - 1 downto 0 loop + digit := 0; + for idx in 3 downto 0 loop + digit := 2 * digit; + if padded(4 * nibble + idx) = '1' then + digit := digit + 1; + end if; + end loop; + if digit /= 0 then + leading := false; + end if; + if not leading then + swrite(result, string'(1 => hex_digits(digit + 1))); + end if; + end loop; + if leading then + swrite(result, "0"); + end if; + + if negative then + return "-" & result.all; + end if; + return result.all; + end; + + -- The error reported for a vector value that cannot be converted + function p_metavalue_error(value : std_logic_vector; operation : string) return string is + begin + return operation & " cannot convert """ & to_string(value) & """; the value has metavalues"; + end; + + impure function p_arg_value(value : unsigned; operation : string) return string is + begin + if p_has_metavalue(std_logic_vector(value)) then + failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + return ""; + end if; + return p_to_hex_literal(to_x01(std_logic_vector(value)), false); + end; + + impure function p_arg_value(value : signed; operation : string) return string is + variable folded : signed(value'length - 1 downto 0); + variable magnitude : signed(value'length downto 0); + begin + if p_has_metavalue(std_logic_vector(value)) then + failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + return ""; + end if; + if value'length = 0 then + return "0x0"; + end if; + + folded := signed(to_x01(std_logic_vector(value))); + if folded(folded'left) = '0' then + return p_to_hex_literal(std_logic_vector(folded), false); + end if; + -- Resizing before negating keeps the magnitude of signed'low representable + magnitude := -resize(folded, value'length + 1); + return p_to_hex_literal(std_logic_vector(magnitude), true); + end; + +$value_arg_functions + + ----------------------------------------------------------------------------- + -- Keyword argument groups + ----------------------------------------------------------------------------- + -- A group of keyword arguments is a positional argument whose Python source + -- text is **dict(a=1, b=2), spliced into the call like any other positional + -- argument. The text of an arg(string) value is quoted and the text of the + -- other positional values is a number, a list or a name, so none of them can + -- be mistaken for a group. + constant p_group_prefix : string := "**dict("; + + function p_is_group(value : arg_t) return boolean is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name /= p_positional_arg or text'length < p_group_prefix'length then + return false; + end if; + return text(1 to p_group_prefix'length) = p_group_prefix; + end; + + -- The keyword arguments of an operand as they are written within **dict(...) + function p_group_items(value : arg_t) return string is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name = p_positional_arg then + -- A group: drop the **dict( prefix and the trailing ) + return text(p_group_prefix'length + 1 to text'length - 1); + end if; + return value.name & "=" & value.value; + end; + + impure function "&"(l, r : arg_t) return arg_t is + begin + if l.name = p_ignore_arg then + return r; + elsif r.name = p_ignore_arg then + return l; + elsif (l.name = p_positional_arg and not p_is_group(l)) or + (r.name = p_positional_arg and not p_is_group(r)) then + failure(python_logger, "Only keyword arguments can be combined with & into a keyword argument group"); + return null_arg; + end if; + + return (p_positional_arg, p_group_prefix & p_group_items(l) & ", " & p_group_items(r) & ")"); + end; + + ----------------------------------------------------------------------------- + -- Operations implemented by the Python bridge (NVC, GHDL and Questa) + ----------------------------------------------------------------------------- + -- Python represents std_logic values by these characters + constant p_std_logic_characters : string(1 to 9) := "UX01ZWLH-"; + + function p_to_std_logic(value : character) return std_logic is + begin + for idx in p_std_logic_characters'range loop + if p_std_logic_characters(idx) = value then + return std_logic'val(idx - 1); + end if; + end loop; + -- The bridge only returns the characters above + return 'X'; + end; + + -- The elements of the characters, the first character becoming the leftmost element + function p_to_std_logic_vector(value : string) return std_logic_vector is + alias normalized : string(1 to value'length) is value; + variable result : std_logic_vector(value'length - 1 downto 0); + begin + for idx in normalized'range loop + result(value'length - idx) := p_to_std_logic(normalized(idx)); + end loop; + return result; + end; + + ----------------------------------------------------------------------------- + -- The Python expression calling identifier with the given arguments + ----------------------------------------------------------------------------- + function p_to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg + ) return string is + variable result : line; + variable first : boolean := true; + + procedure append(value : arg_t) is + begin + if value.name = p_ignore_arg then + return; + end if; + if not first then + swrite(result, ", "); + end if; + first := false; + if value.name = p_positional_arg then + swrite(result, value.value); + else + swrite(result, value.name & "=" & value.value); + end if; + end; + begin + swrite(result, identifier & "("); + append(arg1); + append(arg2); + append(arg3); + append(arg4); + append(arg5); + append(arg6); + append(arg7); + append(arg8); + append(arg9); + append(arg10); + swrite(result, ")"); + + return result.all; + end; + + ----------------------------------------------------------------------------- + -- arg and kwarg + ----------------------------------------------------------------------------- + impure function p_arg_value(value : integer_array_t; operation : string) return string is + constant staged_id : integer := p_stage_array(value, operation); + begin + if staged_id < 1 then + return ""; + end if; + return "__vunit__.staged(" & integer'image(staged_id) & ")"; + end; + +$arg_functions + + ----------------------------------------------------------------------------- + -- exec + ----------------------------------------------------------------------------- + procedure exec_file(file_name : string; session : python_session_t := default_session) is + variable ok : boolean; + begin + ok := p_exec_file(file_name, session); + end; + + ----------------------------------------------------------------------------- + -- eval + ----------------------------------------------------------------------------- +$eval_subprograms + + ----------------------------------------------------------------------------- + -- call + ----------------------------------------------------------------------------- +$call_subprograms +end package body; From b55dbf4fe33ba7c32b2ea0e65d6f9b48a5723148 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Sun, 13 Sep 2026 14:54:12 +0200 Subject: [PATCH 03/28] Add the embedded Python example 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 --- examples/vhdl/embedded_python/bump.py | 21 + .../data/default/input_stimuli.csv | 1 + .../data/extras/input_stimuli2.csv | 1 + examples/vhdl/embedded_python/device_model.py | 114 +++ .../vhdl/embedded_python/fixed_point_model.py | 33 + examples/vhdl/embedded_python/golden_model.py | 29 + examples/vhdl/embedded_python/image_model.py | 25 + examples/vhdl/embedded_python/python_model.py | 13 + .../vhdl/embedded_python/python_model.vhd | 34 + examples/vhdl/embedded_python/run.py | 155 ++++ examples/vhdl/embedded_python/tb_example.vhd | 770 ++++++++++++++++++ 11 files changed, 1196 insertions(+) create mode 100644 examples/vhdl/embedded_python/bump.py create mode 100644 examples/vhdl/embedded_python/data/default/input_stimuli.csv create mode 100644 examples/vhdl/embedded_python/data/extras/input_stimuli2.csv create mode 100644 examples/vhdl/embedded_python/device_model.py create mode 100644 examples/vhdl/embedded_python/fixed_point_model.py create mode 100644 examples/vhdl/embedded_python/golden_model.py create mode 100644 examples/vhdl/embedded_python/image_model.py create mode 100644 examples/vhdl/embedded_python/python_model.py create mode 100644 examples/vhdl/embedded_python/python_model.vhd create mode 100644 examples/vhdl/embedded_python/run.py create mode 100644 examples/vhdl/embedded_python/tb_example.vhd diff --git a/examples/vhdl/embedded_python/bump.py b/examples/vhdl/embedded_python/bump.py new file mode 100644 index 000000000..af3739e5a --- /dev/null +++ b/examples/vhdl/embedded_python/bump.py @@ -0,0 +1,21 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +A Python file with state of its own, used by tb_example.vhd to show the +difference between executing a file and importing it as a module. +""" + +counter = 0 + + +def bump(): + """ + Count one more call. + """ + global counter # pylint: disable=global-statement + counter += 1 + return counter diff --git a/examples/vhdl/embedded_python/data/default/input_stimuli.csv b/examples/vhdl/embedded_python/data/default/input_stimuli.csv new file mode 100644 index 000000000..2739d724d --- /dev/null +++ b/examples/vhdl/embedded_python/data/default/input_stimuli.csv @@ -0,0 +1 @@ +1, 2, 3 diff --git a/examples/vhdl/embedded_python/data/extras/input_stimuli2.csv b/examples/vhdl/embedded_python/data/extras/input_stimuli2.csv new file mode 100644 index 000000000..1ddc48e13 --- /dev/null +++ b/examples/vhdl/embedded_python/data/extras/input_stimuli2.csv @@ -0,0 +1 @@ +10, 20, 30 diff --git a/examples/vhdl/embedded_python/device_model.py b/examples/vhdl/embedded_python/device_model.py new file mode 100644 index 000000000..64bfb6462 --- /dev/null +++ b/examples/vhdl/embedded_python/device_model.py @@ -0,0 +1,114 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +The model of an imaginary device, imported from VHDL with +import_module_from_file. Keeping a model in a module rather than in exec +strings gives syntax highlighting, linting and a debugger. +""" + +import linecache +import math +import traceback + +# The status register of the imaginary device this module models +STATUS = 0xBEEF + + +def check_status(samples, **status): + """ + A register dump checker taking the samples positionally and every status + register as a keyword argument. It verifies a few of the registers and + returns the number of registers it was given. + """ + if len(samples) != 5: + raise ValueError(f"Expected 5 samples, got {len(samples)}") + if status["temperature"] != 42: + raise ValueError(f"Expected temperature 42, got {status['temperature']}") + if status["fifo_level"] > status["fifo_depth"]: + raise ValueError("The FIFO level cannot exceed the FIFO depth") + return len(status) + + +def status_text(cycles, offset, value=0, flag=False): + """ + Show the values exactly as Python received them, so that VHDL can check + that nothing was lost or wrapped on the way. + """ + return f"cycles={cycles} offset={offset} value={value} flag={flag}" + + +def roundtrip(value): + """ + Return the value unchanged, to send a wide integer back to VHDL. + """ + return value + + +def status_word(): + """ + The status register, for call_unsigned. + """ + return STATUS + + +def status_bits(): + """ + The status register as std_logic characters, for call_std_logic_vector. + """ + return format(STATUS, "016b") + + +def is_busy(): + """ + True while the device has work left, for eval_boolean and call_boolean. + """ + return bool(STATUS & 1) + + +def parity(): + """ + Odd parity of the status register as a std_logic character, for call_std_logic. + """ + return "1" if bin(STATUS).count("1") % 2 else "0" + + +def status_name(): + """ + The name of the state the device is in, for call_string. + """ + return "running" + + +def coefficients(): + """ + The filter coefficients of the device, for call_real_vector. + """ + return [0.5, -0.25, 0.125] + + +def sine_table(length): + """ + A quarter wave sine table with the given number of 16 bit entries, for + call_integer_vector_ptr. An empty table is a configuration error. + """ + if length < 1: + raise ValueError(f"A sine table needs at least one entry, got {length}") + return [round(32767 * math.sin(math.pi / 2 * index / length)) for index in range(length)] + + +def failure_text(expression, context): + """ + The traceback that python_pkg puts in its failure message when expression + raises, obtained by evaluating the expression here in the same way. It is + only needed because the test checks the logged message exactly. + """ + linecache.cache[context] = (len(expression), None, expression.splitlines(True), context) + try: + eval(compile(expression, context, "eval")) # pylint: disable=eval-used + except BaseException as exc: # pylint: disable=broad-except + return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__.tb_next)).rstrip("\n") + return "" diff --git a/examples/vhdl/embedded_python/fixed_point_model.py b/examples/vhdl/embedded_python/fixed_point_model.py new file mode 100644 index 000000000..256450454 --- /dev/null +++ b/examples/vhdl/embedded_python/fixed_point_model.py @@ -0,0 +1,33 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +The fixed point model of the same integrator, executed in the fixed_point +session of tb_example.vhd. It rounds every step to a whole number, which is +what the implementation does. Both models define config and model, which is +why they need a session each. +""" + +import numpy as np + +config = dict(name="fixed_point", gain=0.5) + + +class Integrator: + """ + An integrator accumulating gain * sample, rounded to a whole number. + """ + + def __init__(self, gain): + self.gain = gain + self.state = 0 + + def step(self, sample): + self.state += int(np.round(self.gain * sample)) + return float(self.state) + + +model = Integrator(config["gain"]) diff --git a/examples/vhdl/embedded_python/golden_model.py b/examples/vhdl/embedded_python/golden_model.py new file mode 100644 index 000000000..afbbcfd01 --- /dev/null +++ b/examples/vhdl/embedded_python/golden_model.py @@ -0,0 +1,29 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +The golden model of an integrator, executed in the golden session of +tb_example.vhd. It computes in full precision. +""" + +config = dict(name="golden", gain=0.5) + + +class Integrator: + """ + An integrator accumulating gain * sample. + """ + + def __init__(self, gain): + self.gain = gain + self.state = 0.0 + + def step(self, sample): + self.state += self.gain * sample + return self.state + + +model = Integrator(config["gain"]) diff --git a/examples/vhdl/embedded_python/image_model.py b/examples/vhdl/embedded_python/image_model.py new file mode 100644 index 000000000..70913f961 --- /dev/null +++ b/examples/vhdl/embedded_python/image_model.py @@ -0,0 +1,25 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Image processing with NumPy, imported from VHDL with import_module_from_file. +""" + +import numpy as np + + +def transpose(image): + """ + The transpose of a 2-D image, computed by NumPy. + """ + return np.ascontiguousarray(image.T) + + +def pixel(image, x, y): + """ + The pixel in column x of row y. VHDL get(a, x, y) is Python a[y, x]. + """ + return int(image[y, x]) diff --git a/examples/vhdl/embedded_python/python_model.py b/examples/vhdl/embedded_python/python_model.py new file mode 100644 index 000000000..5ff60590b --- /dev/null +++ b/examples/vhdl/embedded_python/python_model.py @@ -0,0 +1,13 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +The behaviour of the python_model component in tb_example.vhd. +""" + + +def compute(x): + return 2 * x + 1 diff --git a/examples/vhdl/embedded_python/python_model.vhd b/examples/vhdl/embedded_python/python_model.vhd new file mode 100644 index 000000000..b916482b1 --- /dev/null +++ b/examples/vhdl/embedded_python/python_model.vhd @@ -0,0 +1,34 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com +-- +-- The behaviour of this component is a Python function, loaded from +-- model_file. The VHDL only passes values between the ports and the model. + +library vunit_lib; +context vunit_lib.python_context; + +entity python_model is + generic(model_file : string); + port( + x : in integer; + y : out integer + ); +end entity; + +architecture python of python_model is +begin + model : process is + begin + -- Wait for the first input so that the model is only loaded if used + wait on x; + exec_file(model_file); + + loop + y <= call("compute", arg(x)); + wait on x; + end loop; + end process; +end architecture; diff --git a/examples/vhdl/embedded_python/run.py b/examples/vhdl/embedded_python/run.py new file mode 100644 index 000000000..64592b7da --- /dev/null +++ b/examples/vhdl/embedded_python/run.py @@ -0,0 +1,155 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Embedded Python +--------------- + +Demonstrates calling Python from VHDL with ``add_python()``: executing Python +code and calling Python functions, for example NumPy/Matplotlib reference +models, from a testbench. The test cases cover ``exec`` and ``eval`` with the +types they convert, calls with positional, keyword and keyword group +arguments, wide ``unsigned``/``signed`` and ``std_logic`` argument values, an +``integer_array_t`` image shared with NumPy, Python files executed with +``exec_file`` or imported with ``import_module_from_file``, two models loaded +into a session each, error reporting, and ``python_model``, a verification +component whose behaviour is a Python function. Some tests need Python +packages VUnit does not depend on (``PySimpleGUI``, ``python-constraint``, +``crccheck`` and ``matplotlib``); the run script says which when they are +missing. Three tests demonstrate error reporting and fail by design. See +:ref:`python_bridge`. +""" + +import importlib.util +from pathlib import Path +from vunit import VUnit + +# Test, the module it imports and the package that provides it +OPTIONAL_PACKAGES = [ + ("Test GUI browsing for input stimuli file", "PySimpleGUI", "PySimpleGUI"), + ("Test querying for randomization seed", "PySimpleGUI", "PySimpleGUI"), + ("Test controlling progress of simulation", "PySimpleGUI", "PySimpleGUI"), + ("Test constraint solving", "constraint", "python-constraint"), + ("Test using a Python module as the golden reference", "crccheck", "crccheck"), + ("Test using Python in an behavioral model", "crccheck", "crccheck"), + ("Test simple plot", "matplotlib", "matplotlib"), + ("Test advanced plot", "matplotlib", "matplotlib"), +] + + +def missing_package(test_name, package): + """ + A pre_config hook failing the test with the package it needs. + """ + + def pre_config(output_path): # pylint: disable=unused-argument + print(f"{test_name} needs the Python package {package}: pip install {package}") + return False + + return pre_config + + +def hello_world(): + print("Hello World") + + +class Plot: + + def __init__(self, x_points, y_limits, title, x_label, y_label): + from matplotlib import pyplot as plt + + # Create plot with a line based on x and y vectors before they have been calculated + # Starting with an uncalculated line and updating it as we calculate more points + # is a trick to make the rendering of the plot quicker. This is not a bottleneck + # created by the VHDL package but inherent to the Python matplotlib package. + fig = plt.figure() + plt.xlabel(x_label) + plt.ylabel(y_label) + plt.title(title) + plt.xlim(x_points[0], x_points[-1]) + plt.ylim(*y_limits) + x_vector = [x_points[0]] * len(x_points) + y_vector = [(y_limits[0] + y_limits[1]) / 2] * len(x_points) + (line,) = plt.plot(x_vector, y_vector, "r-") + fig.canvas.draw() + fig.canvas.flush_events() + plt.show(block=False) + + self.plt = plt + self.fig = fig + self.x_vector = x_vector + self.y_vector = y_vector + self.line = line + + def update(self, x, y): + self.x_vector[x] = x + self.y_vector[x] = y + self.line.set_xdata(self.x_vector) + self.line.set_ydata(self.y_vector) + self.fig.canvas.draw() + self.fig.canvas.flush_events() + + def close(self): + # Some extra code to allow showing the plot without blocking + # the test indefinitely if window isn't closed. + timer = self.fig.canvas.new_timer(interval=5000) + timer.add_callback(self.plt.close) + timer.start() + self.plt.show() + + +def main(): + root = Path(__file__).parent + + vu = VUnit.from_argv() + vu.add_vhdl_builtins() + vu.add_python() + vu.add_random() + # add_python() builds the foreign language application of the simulator + # (the Python bridge, or the VHPI application) under the output path. + + lib = vu.add_library("lib") + lib.add_source_files(root / "*.vhd") + + vu.set_compile_option("rivierapro.vcom_flags", ["-dbg"]) + vu.set_sim_option("rivierapro.vsim_flags", ["-interceptcoutput"]) + # Crashes RPRO for some reason. TODO: Fix when the C code is properly + # integrated into the project. Must be able to debug the C code. + # vu.set_sim_option("rivierapro.vsim_flags" , ["-cdebug"]) + + tb = lib.test_bench("tb_example") + + # These tests need Python packages that VUnit does not depend on. A test + # whose package is missing fails at once with a message saying what to + # install; --without-attributes .optional_deps leaves them all out. + missing = {} + for test_name, module, package in OPTIONAL_PACKAGES: + test = tb.test(test_name) + test.set_attribute(".optional_deps", None) + if importlib.util.find_spec(module) is None: + missing.setdefault(package, []).append(test_name) + test.set_pre_config(missing_package(test_name, package)) + if missing: + print("Some tests need Python packages that are not installed:") + for package, test_names in missing.items(): + print(f" {package}: {', '.join(test_names)}") + print(f"Install them with: pip install {' '.join(missing)}") + print("or leave those tests out with: --without-attributes .optional_deps\n") + + # These tests demonstrate error reporting and fail by design; + # --without-attributes .expected_failure leaves them out. + for test_name in ( + "Test syntax error", + "Test type error", + "Test Python exception", + ): + tb.test(test_name).set_attribute(".expected_failure", None) + + vu.main() + + +if __name__ == "__main__": + main() diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd new file mode 100644 index 000000000..48d3adbf1 --- /dev/null +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -0,0 +1,770 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this file, +-- You can obtain one at http://mozilla.org/MPL/2.0/. +-- +-- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +library vunit_lib; +context vunit_lib.vunit_context; +context vunit_lib.python_context; +use vunit_lib.random_pkg.all; + +library ieee; +use ieee.std_logic_1164.all; +use ieee.math_real.all; +use ieee.numeric_std.all; +use ieee.numeric_std_unsigned.all; + +entity tb_example is + generic( + runner_cfg : string; + debug_mode : boolean := true + ); +end entity; + +architecture tb of tb_example is + constant clk_period : time := 10 ns; + signal clk : std_logic := '0'; + signal in_tvalid, in_tlast : std_logic := '0'; + signal in_tdata : std_logic_vector(7 downto 0); + signal out_tvalid, out_tlast : std_logic := '0'; + signal out_tdata : std_logic_vector(7 downto 0); + signal crc_error : std_logic; + + -- Ports of the python_model component + signal model_x : integer := 0; + signal model_y : integer; +begin + test_runner : process + constant pi : real := 3.141592653589793; + constant test_real_vector : real_vector := (-3.4028234664e38, -1.9, 0.0, 1.1, -3.4028234664e38); + constant crc : std_logic_vector(7 downto 0) := x"17"; + constant expected : std_logic_vector(7 downto 0) := x"21"; + + variable gcd, vhdl_integer : integer; + variable test_input, packet : integer_vector_ptr_t; + variable y : real; + variable coordinate : real_vector(1 to 3); + variable input_stimuli : integer_array_t; + variable seed : integer; + variable test_lengths : integer_vector_ptr_t; + variable vhdl_real : real; + + -- A session is a Python namespace of its own, which is what makes it + -- possible to have two models that both define model and config + constant golden : python_session_t := "golden"; + constant fixed_point : python_session_t := "fixed_point"; + + variable cycles : unsigned(63 downto 0) := x"123456789ABCDEF0"; + variable value : unsigned(31 downto 0) := x"DEADBEEF"; + variable offset : signed(15 downto 0) := to_signed(-12345, 16); + variable received : unsigned(63 downto 0); + variable status : unsigned(15 downto 0); + variable image, transposed : integer_array_t; + variable coefficients : real_vector(0 to 2); + variable table : integer_vector_ptr_t; + + procedure set_tcl_installation is + begin + exec("from os import environ"); + exec("from sys import prefix"); + exec("from pathlib import Path"); + exec("old_environ = environ"); + exec( + "if (Path(prefix) / 'lib' / 'tcl8.6').exists():" + + " environ['TCL_LIBRARY'] = str(Path(prefix) / 'lib' / 'tcl8.6')" + + "else:" + + " environ['TCL_LIBRARY'] = str(Path(prefix) / 'tcl' / 'tcl8.6')" + ); + exec( + "if (Path(prefix) / 'lib' / 'tk8.6').exists():" + + " environ['TK_LIBRARY'] = str(Path(prefix) / 'lib' / 'tk8.6')" + + "else:" + + " environ['TK_LIBRARY'] = str(Path(prefix) / 'tcl' / 'tk8.6')" + ); + end; + + procedure unset_tcl_installation is + begin + exec("environ = old_environ"); + end; + + procedure query_if(expr : boolean; check_result : check_result_t) is + variable logger : logger_t; + variable log_level : log_level_t; + variable line_num : natural; + variable continue : boolean; + alias log_check_result is log[check_result_t]; -- TODO: Fix that location preprocessing is too friendly with this + begin + if not is_pass(check_result) then + if expr then + log_level := get_log_level(check_result); + logger := get_logger(get_checker(check_result)); + line_num := get_line_num(check_result); + + -- Make the query + -- @formatter:off + if line_num = 0 then + continue := eval("psg.popup_yes_no('" & get_msg(check_result) & "\nDo you want to Continue?', title = '" & + title(to_string(get_log_level(check_result))) & "')") = string'("Yes"); + else + continue := eval("psg.popup_yes_no('" & get_msg(check_result) & "\nDo you want to Continue?', title = '" & + title(to_string(get_log_level(check_result))) & " at " & get_file_name(check_result) & " : " & + to_string(line_num) & "')") = string'("Yes"); + end if; + -- @formatter:on + + if continue then + -- If needed, increase stop count to prevent simulation stop + if not has_stop_count(logger, log_level) then + set_stop_count(logger, log_level, 2); + elsif get_log_count(logger, log_level) = get_stop_count(logger, log_level) - 1 then + set_stop_count(logger, log_level, get_log_count(logger, log_level) + 2); + end if; + end if; + end if; + + log_check_result(check_result); + end if; + end; + + -- TODO: Time to make this part of the VUnit release even though it won't be supported for VHDL-93 + impure function new_integer_vector_ptr( + vec : integer_vector; + mode : storage_mode_t := internal; + eid : index_t := -1 + ) return integer_vector_ptr_t is + variable result : integer_vector_ptr_t; + constant vec_normalized : integer_vector(0 to vec'length - 1) := vec; + begin + result := new_integer_vector_ptr(vec'length, mode => mode, eid => eid); + + for idx in vec_normalized'range loop + set(result, idx, vec_normalized(idx)); + end loop; + + return result; + end; + + -- The model of the device used in some of the examples below is the Python + -- 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"); + end; + + begin + test_runner_setup(runner, runner_cfg); + + -- To avoid mixup with the Riviera-PRO TCL installation I had to + -- set the TCL_LIBRARY and TK_LIBRARY environment variables + -- to the Python installation. TODO: Find a better way if possible + set_tcl_installation; + + show(display_handler, debug); + + -- @formatter:off + while test_suite loop + ------------------------------------------------------------------------------------- + -- Basic eval and exec examples + ------------------------------------------------------------------------------------- + if run("Test basic exec and eval") then + -- python_pkg provides the Python functions exec and eval. + -- exec executes a string of Python code while eval evaluates + -- a string containing a Python expression. Only eval returns + -- a value. + exec("a = -17"); + check_equal(eval("abs(a)"), 17); + + elsif run("Test multiline code") then + -- Multiline code can be expressed in several ways. At least + + -- 1. Using multiple eval/exec calls: + exec("a = 5"); + exec("b = 2 * a"); + check_equal(eval("b"), 10); + + -- 2. Using semicolon: + exec("from math import factorial; a = factorial(5)"); + check_equal(eval("a"), 120); + + -- 3. Using LF: + exec( + "l = ['a', 'b', 'c']" & LF & + "s = ', '.join(l)" + ); + check_equal(eval("s"), string'("a, b, c")); + + -- 4. Using + as a shorthand for & LF &: + exec( + "def fibonacci(n):" + + " if n == 0:" + + " return [0]" + + " elif n == 1:" + + " return [0, 1]" + + " elif n > 1:" + + " result = fibonacci(n - 1)" + + " result.append(sum(result[-2:]))" + + " return result" + + " else:" + + " return []"); + + exec("print(f'fibonacci(7) = {fibonacci(7)}')"); + + elsif run("Test working with Python types") then + -- Since exec and eval work with strings, VHDL types + -- must be converted to a suitable string representation + -- before being used. + -- + -- Sometimes to_string is enough + exec("an_integer = " & to_string(17)); + check_equal(eval("an_integer"), 17); + + -- Sometimes a helper function is needed + exec("a_list = " & to_py_list_str(integer_vector'(1, 2, 3, 4))); + check_equal(eval("sum(a_list)"), 10); + + -- For eval it is simply a matter of using a suitable + -- overloaded variant + coordinate := eval("[1.5, -3.6, 9.1]"); -- coordinate is real_vector(1 to 3) + check_equal(coordinate(1), 1.5); + check_equal(coordinate(2), -3.6); + check_equal(coordinate(3), 9.1); + + -- Every eval has a more explicit alias that may be needed to help the compiler + exec("from math import pi"); + vhdl_real := eval_real("pi"); + info("pi = " & to_string(vhdl_real)); + + -- Since Python is more dynamically typed than VHDL it is sometimes useful to use + -- dynamic VUnit types + exec( + "from random import randint" + + "test_input = [randint(0, 255) for i in range(randint(1, 100))]"); + test_input := eval("test_input"); -- test_input is a variable of integer_vector_ptr_t type + check(length(test_input) >= 1); + check(length(test_input) <= 100); + + elsif run("Test result types of eval and call") then + -- eval and call convert the Python value to the VHDL type the context expects. + -- The result types a function cannot size by itself are procedures instead. + import_device_model; + + -- A boolean result can be used directly as a condition + if eval("device_model.is_busy()") then + info("The device reports that it is busy"); + else + check_failed("The device should report that it is busy"); + end if; + 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")); + check_equal(call_std_logic_vector("device_model.status_bits"), std_logic_vector'(x"BEEF")); + + -- The procedure form takes the width of the result from the variable it writes to + call_unsigned("device_model.status_word", status); + check_equal(status, unsigned'(x"BEEF")); + + -- Strings, real vectors and dynamically sized integer vectors + check_equal(call_string("device_model.status_name"), "running"); + coefficients := call_real_vector("device_model.coefficients"); + check_equal(coefficients(0), 0.5); + check_equal(coefficients(2), 0.125); + table := call_integer_vector_ptr("device_model.sine_table", arg(4)); + check_equal(length(table), 4); + check_equal(get(table, 0), 0); + check_equal(get(table, 3), 30273); + + elsif run("Test run script functions") then + -- As we've seen we can define Python functions with exec (fibonacci) and we can import functions from + -- Python packages. Writing large functions in exec strings is not optimal since we don't + -- have the support of a Python-aware editor (syntax highlighting etc). Instead we can put our functions + -- in a Python module, make sure that the module is on the Python search path, and then use a regular import. + -- However, sometimes it is convenient to simply import a function defined in the run script without + -- doing any extra steps. That approach is shown below. + + -- Without a parameter to the import_run_script procedure, the imported module will be named after the + -- run script. In this case the run script is run.py and the module is named run. + import_run_script; + + -- Now we can call functions defined in the run script + exec("run.hello_world()"); + + -- We can also give a name to the imported module + import_run_script("my_run_script"); + exec("my_run_script.hello_world()"); + + -- Regardless of module name, direct access is also possible + exec("from my_run_script import hello_world"); + exec("hello_world()"); + + elsif run("Test exec_file and import_module_from_file") then + -- Any Python file can be executed as it is, or imported as a module by its path. + -- The difference shows for a file that has a state of its own. + + -- 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"); + check_equal(integer'(call("bump")), 1); + check_equal(integer'(call("bump")), 2); + + -- Executing it a second time executes counter = 0 again, which is easy to miss + exec_file("bump.py"); + 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"); + check_equal(integer'(call("bumper.bump")), 1); + check_equal(integer'(call("bumper.bump")), 2); + + -- Executing the file once more does not touch the module + exec_file("bump.py"); + check_equal(integer'(call("bumper.bump")), 3, result("for the counter of the imported module")); + + elsif run("Test sessions") then + -- So far all Python code has been running in one and the same namespace. Every + -- operation also takes a session that selects a namespace of its own, which is + -- what makes it possible to have two models of the same integrator, both + -- defining model and config, loaded side by side. + exec_file("golden_model.py", golden); + exec_file("fixed_point_model.py", fixed_point); + + check_equal(eval_string("config['name']", golden), "golden"); + check_equal(eval_string("config['name']", fixed_point), "fixed_point"); + + -- Each model keeps a state of its own. The golden model integrates in full + -- precision while the fixed point model rounds every step. + check_equal(real'(call("model.step", arg(3), session => golden)), 1.5); + check_equal(real'(call("model.step", arg(3), session => fixed_point)), 2.0); + check_equal(real'(call("model.step", arg(3), session => golden)), 3.0); + check_equal(real'(call("model.step", arg(3), session => fixed_point)), 4.0); + + -- Only the names defined by the executed code are separate. Imported modules are + -- shared, so the NumPy the fixed point model imported is the one the golden + -- session gets and an attribute set on it is visible in both. + exec("import numpy as np", golden); + exec("np.vunit_example_marker = 7", fixed_point); + check_equal(eval_integer("np.vunit_example_marker", golden), 7); + + elsif run("Test function call helpers") then + exec("from math import gcd"); -- gcd = greatest common divisor + + -- Function calls are fundamental to using the eval and exec subprograms. To simplify + -- calls with many arguments, there are a number of helper subprograms. Rather than: + gcd := eval("gcd(" & to_string(35) & ", " & to_string(77) & ", " & to_string(119) & ")"); + check_equal(gcd, 7); + + -- we can use the call function: + gcd := call("gcd", arg(35), arg(77), arg(119)); + check_equal(gcd, 7); + + -- Calls within an exec string can also be simplified + exec("gcd = " & to_call_str("gcd", to_string(35), to_string(77), to_string(119))); + check_equal(eval("gcd"), 7); + + -- Calls to functions without a return value can be simplified with the call procedure + call("print", arg(35), arg(77), arg(119)); + + -- Keyword arguments are also suppored. + check_equal(call("round", arg(3.14159), kwarg("ndigits", 3)), 3.142); + + exec("l = [1, 2]"); + call("l.sort", kwarg("reverse", true)); + check_equal(eval("l[0]"), 2); + check_equal(eval("l[1]"), 1); + + elsif run("Test keyword argument groups") then + -- Keyword arguments combined with & become a single argument. That makes it + -- possible to pass more than the 10 arguments call takes, and to build the + -- keyword arguments of a call in steps, for example one group per register block. + import_device_model; + + -- A register dump: five samples passed positionally and 20 status registers + -- passed as one keyword argument group after them. The group uses one of the + -- 10 argument slots no matter how many keyword arguments it holds. + check_equal( + integer'(call( + "device_model.check_status", + arg(integer_vector'(3, 1, 4, 1, 5)), + + -- The data path registers... + (kwarg("temperature", 42) & kwarg("voltage", 3300) & kwarg("fifo_level", 17) & + kwarg("fifo_depth", 64) & kwarg("overflows", 0) & kwarg("underflows", 0) & + kwarg("packets_in", 1024) & kwarg("packets_out", 1024) & kwarg("crc_errors", 0) & + kwarg("dropped", 0)) & + + -- ...and the link registers, as a group of their own + (kwarg("link_up", true) & kwarg("lane_count", 4) & kwarg("gain", 1.5) & + kwarg("threshold", -3) & kwarg("window", 8) & kwarg("mode", string'("loopback")) & + kwarg("revision", 2) & kwarg("uptime", 86400) & kwarg("retries", 3) & + kwarg("errors", 0)) + )), + 20, + result("for the number of status registers Python received") + ); + + elsif run("Test wide integers and status bits") then + -- An unsigned or signed argument of any width becomes an exact Python integer and + -- is therefore not limited to the range of a VHDL integer, while a std_logic + -- argument becomes a bool. These have names of their own rather than being arg + -- overloads since a string literal belongs to every character array type. + import_device_model; + + -- A 64 bit cycle counter, a negative offset and a 32 bit value above 2**31 all + -- reach Python with their exact values + check_equal( + call_string( + "device_model.status_text", + arg_unsigned(cycles), arg_signed(offset), + kwarg_unsigned("value", value) & kwarg("flag", '1') + ), + "cycles=1311768467463790320 offset=-12345 value=3735928559 flag=True" + ); + + -- H and L are read as 1 and 0, here in the flag + check_equal( + call_string( + "device_model.status_text", + arg_unsigned(cycles), arg_signed(offset), + kwarg_unsigned("value", value) & kwarg("flag", 'H') + ), + "cycles=1311768467463790320 offset=-12345 value=3735928559 flag=True" + ); + + -- The width of the result is given by the variable the procedure form writes to + call_unsigned("device_model.roundtrip", received, arg_unsigned(cycles)); + check_equal(received, cycles); + + ------------------------------------------------------------------------------------- + -- Examples related to error management + -- + -- Errors can obviously occur in the executed Python code. They will all cause the + -- test to fail with information about what was the cause + ------------------------------------------------------------------------------------- + elsif run("Test syntax error") then + exec( + "def hello_word():" + + " print('Hello World)"); -- Missing a ' after Hello World + exec("hello_world()"); + + elsif run("Test type error") then + vhdl_integer := eval("1 / 2"); -- Result is a float (0.5) and should be assigned to a real variable + + elsif run("Test Python exception") then + vhdl_integer := eval("1 / 0"); -- Division by zero exception + + elsif run("Test error handling of a Python model") then + -- The failures above are reported on python_logger and that logger can be mocked. + -- That is how a model is tested for what it does with invalid input, here an + -- empty sine table, without the test failing. + import_device_model; + exec("from device_model import sine_table, failure_text"); + + mock(python_logger, failure); + table := call_integer_vector_ptr("sine_table", arg(0)); + check_only_log( + python_logger, + -- The message is the failing operation and the Python traceback, ending with + -- the text of the exception. failure_text reproduces the traceback by + -- evaluating the same expression; the source name in the traceback is the one + -- the failing eval got, the first of this test case since exec is counted + -- separately. + "eval(""sine_table(0)"") failed:" & LF & + call_string( + "failure_text", arg(string'("sine_table(0)")), arg(string'("")) + ), + failure + ); + unmock(python_logger); + + ------------------------------------------------------------------------------------- + -- Examples related to the simulation environment + -- + -- VHDL comes with some functionality to manage the simulation environment such + -- as working with files, directories, time, and dates. While this feature set was + -- extended in VHDL-2019, it is still limited compared to what Python offers and the + -- VHDL-2019 extensions are yet to be widely supported by simulators. Below are some + -- examples of what we can do with Python. + ------------------------------------------------------------------------------------- + elsif run("Test logging simulation platform") then + -- Logging the simulation platform can be done directly from the run script but + -- here we get the information included in the same log used by the VHDL code + exec("from datetime import datetime"); + exec("import platform"); + exec("from vunit import VUnit"); + + info( + colorize("System name: ", cyan) & eval("platform.node()") + + colorize("Operating system: ", cyan) & eval("platform.platform()") + + colorize("Processor: ", cyan) & eval("platform.processor()") + + colorize("Simulator: ", cyan) & eval("VUnit.from_argv().get_simulator_name()") + + colorize("Simulation started: ", cyan) & eval("datetime.now().strftime('%Y-%m-%d %H:%M:%S')") + ); + + elsif run("Test globbing for files") then + -- glob finds all files matching a pattern, in this case all CSV in the directory tree rooted + -- in the testbench directory + exec("from glob import glob"); + exec("stimuli_files = glob('" & join(tb_path(runner_cfg), "**", "*.csv") & "', recursive=True)"); + + for idx in 0 to eval("len(stimuli_files)") - 1 loop + -- Be aware that you may receive backslashes that should be replaced with forward slashes to be + -- VHDL compatible + input_stimuli := load_csv(replace(eval("stimuli_files[" & to_string(idx) & "]"), "\", "/")); + + -- Test with stimuli file... + end loop; + + ------------------------------------------------------------------------------------- + -- Examples of user interactions + -- + -- The end goal should always be fully automated testing but during an exploratory + -- phase and while debugging it can be useful to allow for some user control of + -- the simulation + ------------------------------------------------------------------------------------- + elsif run("Test GUI browsing for input stimuli file") then + if debug_mode then + exec("import PySimpleGUI as psg"); -- Install PySimpleGUI with pip install pysimplegui + input_stimuli := load_csv(replace(eval("psg.popup_get_file('Select input stimuli file')"), "\", "/")); + else + input_stimuli := load_csv(join(tb_path(runner_cfg), "data", "default", "input_stimuli.csv")); + end if; + + elsif run("Test querying for randomization seed") then + if debug_mode then + exec("import PySimpleGUI as psg"); + seed := integer'value(eval("psg.popup_get_text('Enter seed', default_text='1234')")); + else + seed := 1234; + end if; + info("Seed = " & to_string(seed)); + + elsif run("Test controlling progress of simulation") then + exec("import PySimpleGUI as psg"); + + -- We can let the answers to yes/no pop-ups (psg.popup_yes_no) decide how to proceed, + -- for example if the simulation shall continue on an error. Putting that into an + -- action procedure to a check function and we can have a query_if wrapper like this: + query_if(debug_mode, check_equal(crc, expected, result("for CRC"))); + + info("Decided to continue"); + + ------------------------------------------------------------------------------------- + -- Example math functions + -- + -- You can find almost anything in the Python ecosystem so here are just a few + -- examples of situations where that ecosystem can be helpful + ------------------------------------------------------------------------------------- + + -- Have you ever wished VHDL had some a of the features that SystemVerilog has? + -- The support for a constraint solver is an example of something SystemVerilog has + -- and VHDL does not. However, there are Python constraint solvers that might be helpful + -- if a randomization constraint is difficult to solve with a procedural VHDL code. + -- This is not such a difficult problem but just an example of using a Python constraint solver. + elsif run("Test constraint solving") then + exec("from constraint import Problem"); -- Install with pip install python-constraint + exec("from random import choice"); + exec( + "problem = Problem()" + + "problem.addVariables(['x', 'y', 'z'], list(range(16)))" + -- Three variables in the 0 - 15 range + + -- Constrain the variable + "problem.addConstraint(lambda x, y, z: x != z)" + + "problem.addConstraint(lambda x, y, z: y <= z)" + + "problem.addConstraint(lambda x, y, z: x + y == 8)" + + + -- Pick a random solution + "solutions = problem.getSolutions()" + + "solution = choice(solutions)" + ); + + -- Check that the constraints were met + check(eval_integer("solution['x']") /= eval_integer("solution['z']")); + check(eval_integer("solution['y']") <= eval_integer("solution['z']")); + check_equal(eval_integer("solution['x']") + eval_integer("solution['y']"), 8); + + elsif run("Test NumPy arrays both ways") then + -- integer_array_t is the VUnit type for data sets such as images and it is passed + -- to Python as a NumPy array of the same shape. An array returned by Python + -- becomes a new integer_array_t. The indexing matches: get(a, x, y) is a[y, x]. + import_module_from_file(tb_path(runner_cfg) & "image_model.py", "image_model"); + + -- A 4 x 3 image with the column in the ones and the row in the tens + image := new_2d(width => 4, height => 3, bit_width => 8, is_signed => false); + for y in 0 to height(image) - 1 loop + for x in 0 to width(image) - 1 loop + set(image, x, y, 10 * y + x); + end loop; + end loop; + + -- Python sees the pixel VHDL sees, with the indices the other way around + check_equal(integer'(call("image_model.pixel", arg(image), arg(2), arg(1))), get(image, 2, 1)); + + -- NumPy transposes the image and the result comes back as an integer_array_t + transposed := call_integer_array("image_model.transpose", arg(image)); + check_equal(width(transposed), height(image), result("for the width of the transposed image")); + check_equal(height(transposed), width(image), result("for the height of the transposed image")); + for y in 0 to height(image) - 1 loop + for x in 0 to width(image) - 1 loop + check_equal(get(transposed, y, x), get(image, x, y)); + end loop; + end loop; + deallocate(transposed); + + elsif run("Test using a Python module as the golden reference") then + -- In this example we want to test that a receiver correctly accepts + -- a packet with a trailing CRC. While we can generate a correct CRC to + -- our test packets in VHDL, it is easier to use a Python package that + -- already provides most standard CRC calculations. By doing so, we + -- also have an independent opinion of how the CRC should be calculated. + + -- crccheck (install with pip install crccheck) supports more than 100 standard CRCs. + -- Using the 8-bit Bluetooth CRC for this example + exec("from crccheck.crc import Crc8Bluetooth"); + + -- Let's say we should support packet payloads between 1 and 128 bytes and want to test + -- a subset of those. + test_lengths := new_integer_vector_ptr(integer_vector'(1, 2, 8, 16, 32, 64, 127, 128)); + for idx in 0 to length(test_lengths) - 1 loop + -- Randomize a payload but add an extra byte to store the CRC + packet := random_integer_vector_ptr(get(test_lengths, idx) + 1, min_value => 0, max_value => 255); + + -- Calculate and insert the correct CRC + exec("packet = " & to_py_list_str(packet)); + set(packet, get(test_lengths, idx), eval("Crc8Bluetooth.calc(packet[:-1])")); + + -- This is where we would apply the generated packet to the tested receiver and verify that the CRC + -- is accepted as correct + end loop; + + elsif run("Test using Python in an behavioral model") then + -- When writing VHDL behavioral models we want the model to be as high-level as possible. + -- Python creates opportunities to make these models even more high-level + + -- In this example we use the crccheck package to create a behavioral model for a receiver. + -- Such a model would normally be in a separate process or component so the code below is more + -- an illustration of the general idea. We have a minimalistic incoming AXI stream (in_tdata/tvalid/tlast) + -- receiving a packet from a transmitter (at the bottom of this file). The model collects the incoming packet + -- in a dynamic vector (packet). in_tlast becomes active along with the last recevied byte which is also the + -- CRC. At that point the packet collected before the CRC is pushed into Python where the expected CRC is + -- calculated. If it differs from the received CRC, crc_error is set + -- + -- The model also pass the incoming AXI stream to the output (out_tdata/tvalid/tlast) + exec("from crccheck.crc import Crc8Bluetooth"); + packet := new_integer_vector_ptr; + out_tvalid <= '0'; + crc_error <= '0'; + loop + wait until rising_edge(clk); + exit when out_tlast; + out_tvalid <= in_tvalid; + if in_tvalid then + out_tdata <= in_tdata; + out_tlast <= in_tlast; + if in_tlast then + exec("packet = " & to_py_list_str(packet)); + crc_error <= '1' when eval_integer("Crc8Bluetooth.calc(packet)") /= to_integer(in_tdata) else '0'; + else + resize(packet, length(packet) + 1, value => to_integer(in_tdata)); + end if; + end if; + end loop; + check_equal(crc_error, '0'); + out_tvalid <= '0'; + out_tlast <= '0'; + crc_error <= '0'; + + elsif run("Test a verification component written in Python") then + -- Taking the previous example one step further, the behavioral model can be a + -- component of its own. The behaviour of python_model is a Python function and + -- the VHDL only passes values, so this test case never mentions Python. + for idx in 1 to 3 loop + model_x <= idx; + wait for clk_period; + check_equal(model_y, 2 * idx + 1, result("for the output of the model")); + end loop; + + + --------------------------------------------------------------------- + -- Examples of string manipulation + -- + -- Python has tons of functionality for working with string. One of the + -- features most commonly missed in VHDL is the support for format + -- specifiers. Here are two ways of dealing with that + --------------------------------------------------------------------- + elsif run("Test C-style format specifiers") then + exec("from math import pi"); + info(eval("'pi is about %.2f' % pi")); + + elsif run("Test Python f-strings") then + exec("from math import pi"); + info(eval("f'pi is about {pi:.2f}'")); + + --------------------------------------------------------------------- + -- Examples of plotting + -- + -- Simulators have a limited set of capabilities when it comes to + -- Visualize simulation output beyond signal waveforms. Python has + -- almost endless capabilities + --------------------------------------------------------------------- + elsif run("Test simple plot") then + exec("from matplotlib import pyplot as plt"); -- Matplotlib is installed with pip install matplotlib + exec("fig = plt.figure()"); + exec("plt.plot([1,2,3,4,5], [1,2,3,4,5])"); + exec("plt.show()"); + + elsif run("Test advanced plot") then + import_run_script("my_run_script"); + + exec( + "from my_run_script import Plot" + + "plot = Plot(x_points=list(range(360)), y_limits=(-1.1, 1.1), title='Matplotlib Demo'," + + " x_label='x [degree]', y_label='y = sin(x)')"); + + -- A use case for plotting from VHDL is to monitor the progress of slow simulations where we want to update + -- the plot as more data points become available. This is not a slow simulation but the updating of the plot + -- is a bit slow. This is not due to the VHDL code but inherent to Matplotlib. For the use case where we + -- actually have a slow VHDL simulation, the overhead on the Matplotlib is of no significance. + for x in 0 to 359 loop + y := sin(pi * real(x) / 180.0); + + call("plot.update", arg(x), arg(y)); + end loop; + + exec("plot.close()"); + + end if; + end loop; + -- @formatter:on + + -- Revert to old environment variables + unset_tcl_installation; + test_runner_cleanup(runner); + end process; + + clk <= not clk after clk_period / 2; + + packet_transmitter : process is + -- Packet with valid CRC + constant packet : integer_vector := (48, 43, 157, 58, 110, 67, 192, 76, 119, 97, 235, 143, 131, 216, 60, 121, 111); + begin + -- Transmit the packet once + in_tvalid <= '0'; + for idx in packet'range loop + wait until rising_edge(clk); + in_tdata <= to_slv(packet(idx), in_tdata); + in_tlast <= '1' when idx = packet'right else '0'; + in_tvalid <= '1'; + end loop; + wait until rising_edge(clk); + in_tvalid <= '0'; + in_tlast <= '0'; + wait; + end process; + + -- A component whose behaviour is a Python function + python_model_inst : entity work.python_model + generic map(model_file => tb_path(runner_cfg) & "python_model.py") + port map(x => model_x, y => model_y); +end; From af664845a1b2c9434a4203ceb25cb502bf67e84c Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Sun, 13 Sep 2026 14:54:12 +0200 Subject: [PATCH 04/28] Document the VHDL to Python interface Co-Authored-By: Claude Fable 5.1 --- docs/hdl_libraries.rst | 5 + docs/index.rst | 1 + docs/news.d/+python_bridge.feature.rst | 13 + docs/python_bridge/user_guide.rst | 632 +++++++++++++++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 docs/news.d/+python_bridge.feature.rst create mode 100644 docs/python_bridge/user_guide.rst diff --git a/docs/hdl_libraries.rst b/docs/hdl_libraries.rst index a613755f6..b5d132000 100644 --- a/docs/hdl_libraries.rst +++ b/docs/hdl_libraries.rst @@ -24,6 +24,11 @@ VUnit includes several optional libraries in a group named *VHDL builtins* (see Most of the utilities are based on some internal data types providing dynamic arrays and queues (FIFOs). See :ref:`data_types_library`. +With :meth:`add_python() `, testbenches can also execute Python code and call Python +functions through :vunit_file:`python ` (``context vunit_lib.python_context;``). NVC, GHDL and +Questa/ModelSim are served by the VUnit Python bridge, Riviera-PRO/Active-HDL by a VHPI application. +See :ref:`python_bridge`. + Communication ------------- diff --git a/docs/index.rst b/docs/index.rst index 063d1eb44..c61302d4d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ often"* approach through automation. :ref:`Read more ` com/user_guide verification_components/user_guide data_types/user_guide + python_bridge/user_guide .. toctree:: :caption: Reference diff --git a/docs/news.d/+python_bridge.feature.rst b/docs/news.d/+python_bridge.feature.rst new file mode 100644 index 000000000..c2247369c --- /dev/null +++ b/docs/news.d/+python_bridge.feature.rst @@ -0,0 +1,13 @@ +VHDL testbenches can now execute Python code and call Python functions, for +example NumPy reference models, through the ``python_pkg`` API: ``exec``, +``eval``/``eval_``, and ``call``/``arg``/``kwarg``/``to_call_str``, the +latter also taking ``std_logic`` and arbitrarily wide ``unsigned``/``signed`` +argument values and keyword argument groups combined with ``&``. Enable with +``add_vhdl_builtins()`` followed by ``add_python()``. The operations +implemented by the VUnit Python bridge are available on NVC and GHDL +(VHPIDIRECT) and on Questa/ModelSim (FLI): exchanging ``integer_array_t`` +values as NumPy arrays, boolean, std_logic and width-checked vector results, +executing files with ``exec_file`` and isolating models in named sessions. +Riviera-PRO/Active-HDL (VHPI) use a VHPI application, which ``add_python()`` +builds under the output path as well, and lack those operations. See +:ref:`python_bridge`. diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst new file mode 100644 index 000000000..ba02fd4c5 --- /dev/null +++ b/docs/python_bridge/user_guide.rst @@ -0,0 +1,632 @@ +.. _python_bridge: + +Calling Python from VHDL +======================== + +VUnit can embed a Python interpreter in the simulator so that VHDL testbenches +can execute Python code and call Python functions, for example reference +models written with NumPy. The VHDL API, ``python_pkg``/``python_context``, is +enabled with :meth:`add_python() `, after +:meth:`add_vhdl_builtins() `: + +.. code-block:: python + + from vunit import VUnit + + vu = VUnit.from_argv() + vu.add_vhdl_builtins() + vu.add_python() + + lib = vu.add_library("lib") + lib.add_source_files("*.vhd") + + vu.main() + +``python_pkg`` and its foreign language interface, ``python_ffi_pkg``, then +become available through ``python_context``: + +.. code-block:: vhdl + + library vunit_lib; + context vunit_lib.vunit_context; + context vunit_lib.python_context; + + ... + + exec("import numpy as np"); + result := eval("int(np.sum([1, 2, 3]))"); + +Requirements +------------ + +* NVC, GHDL or Questa/ModelSim, through the VUnit Python bridge described + below, or Riviera-PRO/Active-HDL (VHPI) through the VHPI application, which + :meth:`add_python() ` builds in the same way, see + :ref:`python_bridge:other_simulators`. Any other simulator, or calling + :meth:`add_python() ` before + :meth:`add_vhdl_builtins() `, raises a + ``RuntimeError``. +* VHDL-2008 or later. +* CPython 3.10 or later with the standard (GIL) build. Free-threaded builds + are rejected with an error. +* Linux: a C compiler (``cc``, ``gcc`` or ``clang``, or ``CC``) and + the Python development headers (for example the ``python3-dev`` package) + since the bridge library is compiled on first use, see + :ref:`python_bridge:native`. Python must provide a shared ``libpython`` + (``--enable-shared``), which is the case for distribution Pythons, + ``actions/setup-python``, ``uv`` and ``pyenv`` builds with default settings. +* Windows: a 64-bit CPython from python.org (or compatible, such as + ``actions/setup-python`` and ``uv``). No compiler is needed for NVC and GHDL; + Questa uses the MinGW compiler it ships with. MSYS2/MinGW Pythons are not + supported. + +The simulator runs Python in the same environment as VUnit itself, including +an active virtual environment and its installed packages. + +A missing prerequisite, for example a missing C compiler or missing Python +headers, is reported by :meth:`add_python() ` as an +``ERROR`` explaining what to install, after which VUnit exits with code 1. + +.. _python_bridge:setup_and_cleanup: + +python_setup and python_cleanup +-------------------------------- + +Both are optional. ``python_setup`` starts the interpreter eagerly, otherwise +it starts on first use. ``python_cleanup`` releases the values staged for +``integer_array_t`` arguments early; they are released anyway when the +simulator process ends. It does *not* finalize the interpreter, so it is safe +to call even if more Python code runs afterwards (for example in another test +case of the same simulation). Output is flushed by every operation and does not +depend on ``python_cleanup``. + +.. code-block:: vhdl + + test_runner_setup(runner, runner_cfg); + python_setup; -- Optional, the interpreter starts on first use + + ... + + python_cleanup; -- Optional, releases the staged values early + test_runner_cleanup(runner); + +.. _python_bridge:sessions: + +Sessions +-------- + +Every operation that runs Python code or evaluates a Python expression -- +``exec``, ``eval``/``eval_``, ``call``, ``exec_file``, +``import_run_script`` and ``import_module_from_file`` -- takes an optional +trailing parameter, +``session : python_session_t := default_session``, that selects the namespace +the operation runs in. A session is created the first time it is used; the +``default_session`` constant used when the parameter is omitted runs in +``__main__``. + +.. code-block:: vhdl + + constant golden : python_session_t := "golden"; + constant fixed_point : python_session_t := "fixed_point"; + + ... + + exec("x = 1", session => golden); + exec_file("models/fixed_point.py", session => fixed_point); + + expected := eval("model(x)", session => golden); + got := call("model", arg(x), session => fixed_point); + +Since ``session`` comes after up to 10 positional arguments in ``call``, it is +normally given by name, as above. Both sessions in the example can define +``model`` without interfering with each other. + +On Riviera-PRO/Active-HDL (VHPI), only the default session is supported: +passing any other session fails with a clear error. + +Caveats +~~~~~~~ + +Sessions are separate namespaces in *one* Python interpreter, not separate +interpreters (which would not work with NumPy and many other extension +modules). Consequently, only the names defined by the executed code, such as +functions, classes and variables, are separate. Everything else is shared: + +* Imported modules are loaded once and shared by all sessions. A module + imported in one session is the same module object in another session, so + changes to module state, such as ``np.random.seed(...)`` or attributes set + on a module, are visible in all sessions. The same applies to sibling + modules imported by a Python file executed in several sessions. +* ``sys.path``, ``sys.modules``, environment variables, the current directory + and open files are process wide. +* Only the default session runs in ``__main__``. Classes defined in other + sessions report ``__main__`` as their module but cannot be found there, + which matters for example when pickling their instances. +* All sessions end with the simulation. Test cases run in the same simulation + (``run_all_in_same_sim``) share the sessions. + +exec +---- + +``exec`` executes a string of Python source code in a persistent namespace +(``__main__`` by default, or another :ref:`session `) +that lives until the simulation ends. Names defined by one ``exec`` are +therefore visible to later calls to ``exec`` and ``eval`` on the same session. + +.. code-block:: vhdl + + exec("a = -17"); + check_equal(eval("abs(a)"), 17); + +Multiline code can be written with an explicit ``LF``, or with the ``+`` +operator that ``python_pkg`` defines for strings as a shorthand for ``& LF &``: + +.. code-block:: vhdl + + exec( + "def scale(x, gain):" & LF & + " return x * gain" + ); + + exec( + "import numpy as np" + + "" + + "def fibonacci(n):" + + " a, b = 0, 1" + + " for _ in range(n):" + + " a, b = b, a + b" + + " return a" + ); + +Indentation and blank lines (``""``) are preserved exactly. ``+`` only applies +where a ``string`` is expected; ``numeric_std`` arithmetic such as +``unsigned'("0011") + "0001"`` is unaffected. + +eval +---- + +``eval`` evaluates a string containing a Python expression, in the default +session or another :ref:`session `, and converts the +result to the VHDL type expected by the context, through one of these +functions (all aliased ``eval``): + +* ``eval_integer``, returning ``integer`` +* ``eval_real``, returning ``real`` +* ``eval_string``, returning ``string`` +* ``eval_integer_vector``, returning ``integer_vector`` +* ``eval_real_vector``, returning ``real_vector`` +* ``eval_integer_vector_ptr``, returning ``integer_vector_ptr_t`` + +.. code-block:: vhdl + + variable answer : integer; + variable ratio : real; + variable list : integer_vector_ptr_t; + begin + exec("from math import pi"); + answer := eval("6 * 7"); + ratio := eval("pi"); + list := eval("[1, 1, 2, 3, 5, 8]"); + +When the result type is not clear from the context (for example when it is +passed as an argument, or logged with ``info``), the explicit ``eval_`` +name selects the overload: + +.. code-block:: vhdl + + info("pi = " & to_string(eval_real("pi"))); + +On NVC, GHDL and Questa, where the API is implemented by the VUnit Python +bridge, ``eval`` has more result types: + +* ``eval_boolean``, returning ``boolean``. Only ``bool``/``numpy.bool_`` is + accepted. It also makes the result of ``eval`` usable as a condition: + ``if eval("model.is_done()") then``. +* ``eval_std_logic``, returning ``std_logic``. +* ``eval_std_logic_vector``, returning an unconstrained ``std_logic_vector``. +* ``eval_integer_array``, returning ``integer_array_t``, see + :ref:`python_bridge:integer_array`. + +``eval_boolean`` and ``eval_std_logic`` are aliased ``eval`` like the other +result types. ``eval_std_logic_vector`` and ``eval_integer_array`` are not, +since that would make ``check_equal(eval("17"), 17)`` and +``length(eval("[1, 2]"))`` ambiguous: use their explicit names. + +``std_logic_vector``, ``signed`` and ``unsigned`` results are also available +as the procedures ``eval_std_logic_vector``, ``eval_signed`` and +``eval_unsigned``, that take the result as an ``out`` parameter. The width is +given by the actual, and a Python value that does not fit that width is an +error. ``signed`` and ``unsigned`` results are only available in the +procedure form, since a function cannot know the width of the result. + +.. code-block:: vhdl + + variable byte : std_logic_vector(7 downto 0); + variable level : signed(15 downto 0); + begin + eval_std_logic_vector("format(value, '08b')", byte); + eval_signed("model.level()", level); + +call, arg, kwarg and to_call_str +--------------------------------- + +``call`` calls a Python function (or a callable expression such as a dotted +name, ``"np.sum"``, or a method, ``"model.run"``) with up to 10 arguments, +built with ``arg`` (positional) and ``kwarg`` (keyword), and converts the +returned value like ``eval``. Like every other operation, it accepts an +optional trailing :ref:`session ` parameter, normally +given by name since it follows the positional arguments: + +.. code-block:: vhdl + + variable gcd, count : integer; + variable ratio : real; + begin + exec("from math import gcd as py_gcd"); + gcd := call("py_gcd", arg(35), arg(77), arg(119)); + ratio := call("round", arg(3.14159), kwarg("ndigits", 3)); + + -- No return value: the procedure form of call + call("print", arg(35), arg(77), arg(119)); + +``arg`` and ``kwarg`` accept these value types: + +========================== ======================================================== +VHDL Python +========================== ======================================================== +``integer`` ``int`` +``real`` ``float`` +``boolean`` ``bool`` +``string`` ``str`` +``integer_vector`` ``list`` of ``int`` +``real_vector`` ``list`` of ``float`` +``integer_vector_ptr_t`` ``list`` of ``int`` +``std_logic`` ``bool``, 1 and H give ``True``, 0 and L give ``False`` +``unsigned`` ``int``, passed as ``arg_unsigned``/``kwarg_unsigned`` +``signed`` ``int``, passed as ``arg_signed``/``kwarg_signed`` +``integer_array_t`` NumPy array, not on Riviera-PRO/Active-HDL +========================== ======================================================== + +An ``unsigned`` or ``signed`` value of any width becomes an exact Python +integer, so it is not limited to the range of a VHDL ``integer``. The two have +names of their own rather than ``arg`` overloads, since a string literal +belongs to every character array type and an overload would therefore make +``arg("hello")`` ambiguous. ``H`` and ``L`` are read as 1 and 0 in a +``std_logic``, ``unsigned`` or ``signed`` value; any other metavalue is an +error. + +An aggregate or a literal does not select an overload by itself and needs a +qualified expression: ``arg(real_vector'(1.0, 2.0))``, +``arg(integer_vector'(1, 2, 3))``. There is no ``std_logic_vector`` value, +which would be ambiguous for the same reason as ``unsigned``. Such a value is +passed as a number or as the string of its characters: + +.. code-block:: vhdl + + call("model.scale", arg_unsigned(unsigned(gain))); + call("model.push", arg(to_string(slv))); + +On NVC, GHDL and Questa, ``call`` returns the same types as ``eval``: +``call_boolean``, ``call_std_logic``, ``call_std_logic_vector``, +``call_integer_array``, ``call_string``, ``call_real_vector`` and +``call_integer_vector_ptr``, plus the procedures ``call_std_logic_vector``, +``call_signed`` and ``call_unsigned`` taking the result as an ``out`` +parameter. All of the functions but ``call_std_logic_vector`` are aliased +``call``. + +``to_call_str`` builds the Python call expression itself, as a string, which +is useful to embed a call inside a larger ``exec``/``eval`` string: + +.. code-block:: vhdl + + exec("gcd = " & to_call_str("py_gcd", to_string(35), to_string(77))); + +Every unused trailing argument of ``call``/``to_call_str`` defaults to an +ignored placeholder, so calls with fewer than 10 arguments need no padding. + +Keyword argument groups +~~~~~~~~~~~~~~~~~~~~~~~~ + +Keyword arguments combined with ``&`` become a single argument, which makes it +possible to pass more than 10 keyword arguments and to build the keyword +arguments of a call in steps. ``null_arg`` is the identity of the operation and +only keyword arguments, or groups of them, can be combined: + +.. code-block:: vhdl + + -- Calls plot(data, **dict(title="Step response", grid=True)) + call("plot", arg(data), kwarg("title", string'("Step response")) & kwarg("grid", true)); + +The group is passed to Python as ``**dict(...)`` and therefore keeps Python's +own rules: it must come after the positional arguments of the call and a +keyword must not be repeated within it. It uses one of the 10 argument slots, +no matter how many keyword arguments it holds. + +import_run_script +------------------ + +``import_run_script`` imports the VUnit run script as a Python module, so +that functions and classes defined in it (for example a plot helper using +matplotlib) can be called from VHDL without duplicating them in a separate +file: + +.. code-block:: vhdl + + import_run_script; + exec("run.hello_world()"); + + import_run_script("my_run_script"); + exec("my_run_script.hello_world()"); + +Without an explicit name, the module is named after the run script's file +name (without extension), so the run script is normally named ``run.py``. +Because the run script is imported as a module, it must be import-safe: code +that is only meant to run when the script is invoked directly (typically the +call to :meth:`vu.main() `) must be behind +``if __name__ == "__main__":``; a run script without such a guard is +rejected before it is imported. Like the other operations, it takes an +optional trailing :ref:`session ` parameter. + +import_module_from_file and to_py_list_str +-------------------------------------------- + +``import_module_from_file`` imports any Python file as a module by path (also +taking an optional :ref:`session ` parameter), and is +what ``import_run_script`` uses internally: + +.. code-block:: vhdl + + import_module_from_file(tb_path(runner_cfg) & "reference_model.py", "reference_model"); + exec("reference_model.configure(gain=4)"); + +``to_py_list_str`` converts an ``integer_vector``, ``integer_vector_ptr_t`` or +``real_vector`` to the string representation of the equivalent Python list, +for use in ``exec``/``eval`` strings: + +.. code-block:: vhdl + + exec("a_list = " & to_py_list_str(integer_vector'(1, 2, 3, 4))); + check_equal(eval("sum(a_list)"), 10); + +Type mapping +------------ + +* ``integer`` ↔ ``int``. Results outside the VHDL integer range fail. +* ``real`` ↔ ``float``. Strict: an ``int`` result is not accepted. +* ``string`` ↔ ``str``. UTF-8. +* ``boolean`` ↔ ``bool``/``numpy.bool_`` (Python bridge only). +* ``integer_vector`` ↔ ``list`` of ``int``. +* ``real_vector`` ↔ ``list`` of ``float`` (``call_real_vector`` needs the bridge). +* ``integer_vector_ptr_t`` ↔ ``list`` of ``int``. +* ``std_logic``/``std_logic_vector`` ↔ ``str``, one character per element + out of ``U X 0 1 Z W L H -``, left to right (results only, Python bridge). +* ``signed``/``unsigned`` ↔ ``int`` (procedure results only, Python bridge). +* ``integer_array_t`` ↔ ``numpy.ndarray`` (Python bridge), see + :ref:`python_bridge:integer_array`. + +The types marked as needing the Python bridge are available on NVC, GHDL and +Questa, but not on Riviera-PRO/Active-HDL. + +Results are strict: a value that does not fit the VHDL type, or is of the +wrong Python type, is an error. Values are never silently truncated or +wrapped. + +Errors +------ + +Python exceptions, syntax errors, type errors and undefined names are +reported as failures on the ``python_logger`` logger (named +``vunit_lib:python``), including the Python traceback. The test fails and +stops like for any other failure. The logger can be mocked to test error +handling; when mocked, ``eval``/``call`` return a default value (``0``, +``0.0``, ``""`` or an empty vector, depending on the type) after the failure. + +.. code-block:: text + + FAILURE - vunit_lib:python - eval("1 / 0") failed: + Traceback (most recent call last): + File "", line 1, in + 1 / 0 + ~~^~~ + ZeroDivisionError: division by zero + +Output of ``print`` is written to the simulator output and flushed after +every operation. + +.. _python_bridge:integer_array: + +integer_array_t and NumPy +-------------------------- + +.. note:: + + ``integer_array_t`` values are transferred by the VUnit Python bridge and + are therefore available on NVC, GHDL and Questa, but not on + Riviera-PRO/Active-HDL. + +An ``integer_array_t`` argument is transferred to Python and referred to by +the expression as a NumPy array of dtype ``int32``, so it can be reused in +several calls. The shape follows VUnit's indexing so that elements correspond +directly: + +============= ================================ =========================== +Array VHDL Python +============= ================================ =========================== +1D ``get(a, i)`` ``a[i]``, shape ``(length,)`` +2D ``get(a, x, y)`` ``a[y, x]``, shape ``(height, width)`` +3D ``get(a, x, y, z)`` ``a[y, x, z]``, shape ``(height, width, depth)`` +============= ================================ =========================== + +``integer_array_t`` does not record its number of dimensions. An argument is +treated as 3D if its depth is larger than one, else as 2D if its height is +larger than one, else as 1D. + +A returned array (or nested list of integers) becomes a new +``integer_array_t`` with the corresponding width, height and depth. Its +``bit_width`` and ``is_signed`` are those of the argument, if the function +returns one of its ``integer_array_t`` arguments (possibly modified in +place), else given by the dtype: ``bool`` is 1 bit unsigned, ``int8``/``uint8`` +8 bit, ``int16``/``uint16`` 16 bit and all other integer dtypes 32 bit +signed. All values must fit that word size, otherwise the call fails. +Floating point arrays are rejected; convert them explicitly with ``astype``. +As usual, the returned ``integer_array_t`` is owned by the caller and can be +freed with ``deallocate``. + +exec_file +--------- + +``exec_file`` executes a Python file (where the Python bridge implements the +API: NVC, GHDL and Questa), with the equivalent of + +.. code-block:: python + + with open(path, encoding="utf-8") as file: + source = file.read() + exec(compile(source, str(path), "exec"), namespace, namespace) + +which means that tracebacks show the real file name and line numbers, +``__file__`` is set to the absolute path of the file while it executes (and +restored afterwards), and the directory of the file is added to ``sys.path`` +while it executes so that it can import sibling modules without setting +``PYTHONPATH``. Imports are therefore expected at the top level of the file. +Executing the same file twice executes it twice. + +A relative file name is relative to the directory of the VUnit run script +(the script started by ``python``), independently of the simulator and of +the current working directory. That directory is also the run script's own +first entry of ``sys.path``. Absolute paths work too, for example +``tb_path(runner_cfg) & "model.py"`` for a file next to the testbench. +Like the other operations, it takes an optional trailing +:ref:`session ` parameter. + +.. _python_bridge:semantics: + +Semantics to be aware of +------------------------- + +.. important:: + + * ``eval``/``eval_`` are declared ``impure`` since they always read + state from the Python interpreter. + * Errors are reported as failures on the ``python_logger`` logger, + including the Python traceback, rather than aborting the simulation. + * ``python_setup`` and ``python_cleanup`` are optional, see + :ref:`python_bridge:setup_and_cleanup`. ``python_cleanup`` does not + finalize the interpreter. + * ``real`` results have no float32 range limit: VHDL ``real`` is double + precision where the Python bridge implements the API, so any finite + Python ``float`` (also double precision) is in range. A value that is not + finite, such as ``float("inf")`` or ``float("nan")``, has no VHDL ``real`` + representation and is reported as a failure. + * ``string`` arguments are passed to Python double-quoted verbatim: a quote + or backslash inside the string is not escaped, so it must be avoided or + already be valid inside a Python double-quoted string. + +.. _python_bridge:other_simulators: + +Other simulators +----------------- + +Riviera-PRO/Active-HDL (VHPI) implement ``python_ffi_pkg`` with a VHPI +application, built from the C sources in :vunit_file:`vunit/vhdl/python/src +` with their ``ccomp`` driver. :meth:`add_python() +` builds the application under the output path +(``//libraries``) the first time it is called and +rebuilds it when the sources, the Python running VUnit or the simulator +installation change, so a run script needs nothing beyond :meth:`add_python() +`. + +This application differs from the Python bridge in a few ways: only the +default session exists, the operations implemented by the bridge +(``integer_array_t`` values, the ``boolean``, ``std_logic``, vector and +``integer_array_t`` results, ``exec_file``) report that they require NVC, GHDL +or Questa, a Python error stops the simulation with the message printed by the +application rather than through ``python_logger``, and ``real`` values outside +the single precision float range are rejected. + +See :vunit_example:`➚ examples/vhdl/embedded_python ` for +a complete example covering all three simulator families. + +Its ``tb_example.vhd`` has a test case for each part of the API: ``exec`` and +``eval`` with the types they convert, calls with positional, keyword and +keyword group arguments, a 20 register status dump, wide +``arg_unsigned``/``arg_signed`` and ``std_logic`` argument values, a 2-D +``integer_array_t`` image transposed by NumPy, the result types of ``eval`` and +``call``, Python files executed with ``exec_file`` or imported with +``import_module_from_file``, two models loaded into a session each, and a +Python model failing with ``python_logger`` mocked. Its last test case drives +``python_model``, a verification component whose behaviour is the Python +function in ``python_model.py`` rather than VHDL. + +.. automodule:: vunit.python_bridge.foreign_application + +.. _python_bridge:native: + +How it works (NVC, GHDL and Questa) +------------------------------------ + +For NVC, GHDL and Questa, the interpreter is embedded in the simulator process +by a small C library, the VUnit Python bridge (:vunit_file:`vunit/python_bridge/native +`). NVC and GHDL call it through VHPIDIRECT; +Questa/ModelSim calls it through the FLI, using the front end in +:vunit_file:`native/fli.c ` that converts the +FLI parameters of one foreign subprogram per entry point. The generated VHDL +and everything above it is the same for all three. The interpreter is started +on first use, is never restarted within a simulation, and uses no signal +handlers of its own. + +Linux + The bridge is compiled from source against the Python running VUnit the + first time :meth:`add_python() ` is called, and + cached in ``/python_bridge``. It is rebuilt automatically when + the source, the Python version or the Python installation changes. For + Questa the FLI front end is compiled in too, against the ``mti.h`` of the + simulator, and the simulator installation is part of the cache key. VUnit + ships no prebuilt Linux library. + +Windows + For NVC and GHDL, VUnit ships DLLs built with MSVC for each supported Python + minor version (``vunit/python_bridge/bin``). The matching DLL is copied to + the output path and nothing is compiled. A development checkout of VUnit + does not contain the DLLs; they can be built with + ``tools/build_python_bridge.py`` from an MSVC developer prompt. For Questa + the library must be linked against the simulator's ``mtipli``, so it is + built on first use with the MinGW GCC bundled with Questa, against the + headers and the import library of the Python running VUnit. That build is + untested. + +The bridge uses the full (version specific) CPython ABI rather than the +Stable ABI since embedding the interpreter in the environment VUnit runs in +requires the ``PyConfig`` initialization API, which is not part of the +limited API. + +VUnit makes the simulator find the library automatically. NVC is given a +``--load`` option. GHDL gets the library directory in its dynamic library +search path, plus a linker search path for the ahead-of-time compiled llvm +and gcc backends. The FLI attributes generated for Questa name the library by +absolute path, and the vsim processes VUnit starts are given +``-noautoldlibpath`` on Linux so that the C++ runtime Questa bundles, which is +often older than the one the Python extension modules of the environment +(NumPy) were built against, does not take precedence over the one of the +system. No simulator options need to be set by the user. + +Tested configurations +~~~~~~~~~~~~~~~~~~~~~~ + +========================== ======================================================== +Simulator Linux +========================== ======================================================== +NVC 1.22 (and 1.23-devel) +GHDL mcode 6.0.0, 7.0.0-dev +GHDL llvm-jit 6.0.0, 7.0.0-dev +GHDL llvm 6.0.0, 7.0.0-dev +GHDL gcc 6.0.0, 7.0.0-dev +Questa (FLI) Altera Starter FPGA Edition 2025.3 +========================== ======================================================== + +On Windows, CI tests NVC 1.22 and GHDL mcode (nightly) with Python 3.10, 3.12 +and 3.14. + +Questa is tested manually on Linux and is not covered by CI; the Windows build +of its bridge library is untested. Riviera-PRO/Active-HDL (VHPI) is not tested +at all. From cda4e4e887c934eabe159e08291807efcb6a10d0 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Sun, 13 Sep 2026 14:54:12 +0200 Subject: [PATCH 05/28] Add CI for the VHDL to Python interface 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 --- .github/workflows/push.yml | 15 ++ .github/workflows/python_bridge.yml | 218 +++++++++++++++++++++++ .github/workflows/python_bridge_dlls.yml | 63 +++++++ tools/build_python_bridge.py | 101 +++++++++++ 4 files changed, 397 insertions(+) create mode 100644 .github/workflows/python_bridge.yml create mode 100644 .github/workflows/python_bridge_dlls.yml create mode 100644 tools/build_python_bridge.py diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 424132a91..861501ea9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -165,6 +165,14 @@ jobs: - name: '🚧 Run job' run: tox -e py${{ matrix.task }} -- --color=yes +# +# Prebuilt Windows DLLs of the VHDL Python bridge, included in releases +# + + python-bridge-dlls: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: ./.github/workflows/python_bridge_dlls.yml + # # Deploy to PyPI # @@ -177,6 +185,7 @@ jobs: - ghdl - nvc - win + - python-bridge-dlls if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') name: '🚀 Deploy' steps: @@ -186,6 +195,12 @@ jobs: with: submodules: recursive + - name: '📥 Download Python bridge DLLs' + uses: actions/download-artifact@v4 + with: + name: python-bridge-dlls + path: vunit/python_bridge/bin + - name: '🐍 Setup Python' uses: actions/setup-python@v6 with: diff --git a/.github/workflows/python_bridge.yml b/.github/workflows/python_bridge.yml new file mode 100644 index 000000000..77ae5b72b --- /dev/null +++ b/.github/workflows/python_bridge.yml @@ -0,0 +1,218 @@ +name: 'python bridge' + +# Tests of the VHDL Python integration, add_python(), in the environments +# push.yml does not provide: CPython from python.org, on Linux +# (bridge compiled on first use) and Windows (prebuilt MSVC DLLs), and the +# content of the package. + +on: + push: + pull_request: + workflow_dispatch: + +defaults: + run: + shell: bash + +jobs: + +# +# Linux and macOS, bridge compiled on first use +# + + posix: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # The oldest and newest supported CPython; every GHDL backend, since + # the bridge library is loaded differently by the ones that link ahead + # of time (llvm, gcc), on the latest release; the NVC release VUnit is + # developed against. + # + # Canaries (allowed to fail): the GHDL nightly builds and NVC built + # from its master branch, with Python 3.14, where simulator changes + # show up first. + include: + - { os: ubuntu-24.04, py: '3.10', sim: nvc, nvc: '1.22.1' } + - { os: ubuntu-24.04, py: '3.14', sim: nvc, nvc: '1.22.1' } + - { os: ubuntu-24.04, py: '3.14', sim: nvc, nvc: master, canary: true } + - { os: ubuntu-24.04, py: '3.10', sim: ghdl, backend: mcode, ghdl: latest } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: mcode, ghdl: nightly, canary: true } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: llvm-jit, ghdl: latest } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: llvm-jit, ghdl: nightly, canary: true } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: llvm, ghdl: latest } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: llvm, ghdl: nightly, canary: true } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: gcc, ghdl: latest } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl, backend: gcc, ghdl: nightly, canary: true } + - { os: macos-15, py: '3.14', sim: nvc, nvc: brew } + # macos-15: setup-ghdl has no build for newer macOS versions yet, and only the llvm backend + - { os: macos-15, py: '3.14', sim: ghdl, backend: llvm, ghdl: latest } + name: '${{ matrix.os }} · Python ${{ matrix.py }} · ${{ matrix.sim }} ${{ matrix.backend }}${{ matrix.ghdl }}${{ matrix.nvc }}' + continue-on-error: ${{ matrix.canary || false }} + env: + VUNIT_SIMULATOR: ${{ matrix.sim }} + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.py }} + + - name: '⚙️ Setup NVC' + if: matrix.sim == 'nvc' && matrix.nvc != 'brew' && matrix.nvc != 'master' + run: | + curl -fsSL -o nvc.deb "https://github.com/nickg/nvc/releases/download/r${{ matrix.nvc }}/nvc_${{ matrix.nvc }}-1_amd64_ubuntu-24.04.deb" + sudo apt-get install -y ./nvc.deb + + # As in NVC's README + - name: '⚙️ Build NVC from its master branch' + if: matrix.nvc == 'master' + run: | + sudo apt-get install -y -qq build-essential automake autoconf flex check llvm-dev pkg-config zlib1g-dev libdw-dev libffi-dev libzstd-dev + git clone --depth 1 https://github.com/nickg/nvc.git "$RUNNER_TEMP/nvc" + cd "$RUNNER_TEMP/nvc" && ./autogen.sh && mkdir build && cd build && ../configure && make -j"$(nproc)" && sudo make install + nvc --version + + - name: '⚙️ Setup NVC with Homebrew' + if: matrix.nvc == 'brew' + run: brew install nvc + + - name: '⚙️ Setup GHDL' + if: matrix.sim == 'ghdl' + uses: ghdl/setup-ghdl@main + with: + version: ${{ matrix.ghdl }} + backend: ${{ matrix.backend }} + + # The environment and the output go to the runner's temporary directory, + # outside the checkout, so nothing built can end up in the tree. The space + # in the path is deliberate: the bridge builds command lines and simulator + # options from these paths. + - name: '🐍 Install VUnit in a virtual environment' + run: | + python -m venv "$RUNNER_TEMP/venv with space" + echo "$RUNNER_TEMP/venv with space/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/venv with space/bin/pip" install -e . --progress-bar off + + - name: '🚧 Feature tests' + run: python vunit/vhdl/python/run.py -p 4 -o "$RUNNER_TEMP/out dir" + + - name: '🚧 Example' + run: python examples/vhdl/embedded_python/run.py -p 4 --without-attributes .optional_deps --without-attributes .expected_failure + +# +# Windows, prebuilt MSVC DLLs +# + + dlls: + uses: ./.github/workflows/python_bridge_dlls.yml + + windows: + needs: dlls + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + py: ['3.10', '3.12', '3.14'] + sim: [nvc, ghdl] + name: '🟦 Windows · Python ${{ matrix.py }} · ${{ matrix.sim }}' + env: + VUNIT_SIMULATOR: ${{ matrix.sim }} + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: '📥 Download bridge DLLs' + uses: actions/download-artifact@v4 + with: + name: python-bridge-dlls + path: vunit/python_bridge/bin + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.py }} + architecture: x64 + + - name: '⚙️ Setup NVC' + if: matrix.sim == 'nvc' + shell: pwsh + run: | + Invoke-WebRequest -Uri https://github.com/nickg/nvc/releases/download/r1.22.1/nvc-1.22.1.msi -OutFile nvc.msi + Start-Process msiexec.exe -Wait -ArgumentList '/i', 'nvc.msi', '/qn', '/norestart' + $bin = Get-ChildItem -Path 'C:\Program Files*' -Recurse -Filter nvc.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + $bin.DirectoryName | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: '⚙️ Setup GHDL' + if: matrix.sim == 'ghdl' + uses: ghdl/setup-ghdl@main + with: + backend: mcode + + # Same as on Linux: outside the checkout, with a space in the path + - name: '🐍 Install VUnit in a virtual environment' + run: | + python -m venv "$RUNNER_TEMP/venv with space" + echo "$RUNNER_TEMP/venv with space/Scripts" >> "$GITHUB_PATH" + "$RUNNER_TEMP/venv with space/Scripts/pip" install -e . --progress-bar off + + # No compiler on PATH: the prebuilt DLLs must be used + - name: '🚧 Feature tests' + run: | + ! command -v cl + python vunit/vhdl/python/run.py -p 4 -o "$RUNNER_TEMP/out dir" + + - name: '🚧 Example' + run: python examples/vhdl/embedded_python/run.py -p 4 --without-attributes .optional_deps --without-attributes .expected_failure + +# +# Packaging, sources and DLLs included, no Linux binaries +# + + packaging: + needs: dlls + runs-on: ubuntu-latest + name: '📦 Packaging' + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: '📥 Download bridge DLLs' + uses: actions/download-artifact@v4 + with: + name: python-bridge-dlls + path: vunit/python_bridge/bin + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: '📦 Build sdist' + run: | + pip install -U setuptools wheel --progress-bar off + python setup.py sdist + + # Sources of every kind, the five DLLs, no Linux binary + - name: '🔍 Check sdist content' + run: | + tar tzf dist/*.tar.gz > content.txt + for file in python_bridge/runtime.py python_bridge/native/bridge.h vhdl/python/src/python_bridge_pkg.vhd.in \ + vhdl/python/src/python_pkg.vhd vhdl/python/src/python_pkg_vhpi.c \ + python_bridge/foreign_application.py; do + grep -q "vunit/$file\$" content.txt || { echo "missing $file"; exit 1; } + done + test "$(grep -c 'vunit/python_bridge/bin/vunit_python_bridge-cp3[0-9]*-win_amd64.dll$' content.txt)" = 5 + ! grep -q '\.so$' content.txt diff --git a/.github/workflows/python_bridge_dlls.yml b/.github/workflows/python_bridge_dlls.yml new file mode 100644 index 000000000..552eb7f19 --- /dev/null +++ b/.github/workflows/python_bridge_dlls.yml @@ -0,0 +1,63 @@ +name: 'python bridge dlls' + +# Builds the prebuilt Windows DLLs of the VHDL Python bridge with MSVC, one per +# CPython version. Called by python_bridge.yml and by push.yml for releases. + +on: + workflow_call: + +jobs: + + build: + runs-on: windows-latest + strategy: + matrix: + py: ['3.10', '3.11', '3.12', '3.13', '3.14'] + name: '🟦 MSVC · bridge DLL · Python ${{ matrix.py }}' + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.py }} + architecture: x64 + + - name: '⚙️ Setup MSVC' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: '🔨 Build DLL' + run: python tools/build_python_bridge.py --output-dir dlls + + - name: '📤 Upload DLL' + uses: actions/upload-artifact@v4 + with: + name: python-bridge-dll-${{ matrix.py }} + path: dlls/*.dll + if-no-files-found: error + + collect: + needs: build + runs-on: ubuntu-latest + name: '📦 Collect bridge DLLs' + steps: + + - name: '📥 Download DLLs' + uses: actions/download-artifact@v4 + with: + pattern: python-bridge-dll-* + merge-multiple: true + path: dlls + + - name: '🔍 Check DLLs' + run: test "$(ls dlls/*.dll | wc -l)" = 5 + + - name: '📤 Upload DLLs' + uses: actions/upload-artifact@v4 + with: + name: python-bridge-dlls + path: dlls/*.dll diff --git a/tools/build_python_bridge.py b/tools/build_python_bridge.py new file mode 100644 index 000000000..966c38382 --- /dev/null +++ b/tools/build_python_bridge.py @@ -0,0 +1,101 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com + +""" +Build the prebuilt Windows DLL of the VHDL Python bridge for the running Python. + +Must be run on Windows, with the Python version the DLL is built for, from a +shell where the MSVC compiler (cl.exe) is available (e.g. a "Developer +Command Prompt" or the ilammy/msvc-dev-cmd GitHub action). This is used by CI +to produce the DLLs shipped in vunit/python_bridge/bin. End users never need it. + +The Python DLL is delay-loaded; the bridge loads it by absolute path at run +time. That way the simulator does not need the Python installation on PATH. +""" + +import argparse +import importlib.util +import subprocess +import sys +import sysconfig +from pathlib import Path + + +def _load_native_library(): + """ + Load vunit/python_bridge/native_library.py by path, without importing vunit and its dependencies. + """ + path = Path(__file__).parent.parent / "vunit" / "python_bridge" / "native_library.py" + spec = importlib.util.spec_from_file_location("_vunit_python_bridge_native_library", path) + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +NATIVE_LIBRARY = _load_native_library() + + +def main(): + """ + Build the DLL. + """ + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument( + "--output-dir", + type=Path, + default=NATIVE_LIBRARY.BINARY_PATH, + help="Directory to write the DLL to (default: %(default)s)", + ) + args = parser.parse_args() + + if sys.platform != "win32" or sysconfig.get_platform() != "win-amd64": + raise SystemExit("The bridge DLL must be built on 64-bit Windows with a python.org style CPython") + if sysconfig.get_config_var("Py_GIL_DISABLED"): + raise SystemExit("Free-threaded CPython builds are not supported") + + python_lib = f"python{sys.version_info[0]}{sys.version_info[1]}" + include_dir = sysconfig.get_paths()["include"] + libs_dir = Path(sys.base_prefix) / "libs" + if not (libs_dir / f"{python_lib}.lib").is_file(): + raise SystemExit(f"Missing import library {libs_dir / (python_lib + '.lib')}") + + args.output_dir.mkdir(parents=True, exist_ok=True) + output = args.output_dir / NATIVE_LIBRARY.windows_dll_name() + build_dir = args.output_dir / "build" + build_dir.mkdir(exist_ok=True) + + cmd = [ + "cl", + "/nologo", + "/LD", + "/O2", + "/W3", + "/MD", + "/Brepro", + f"/I{include_dir}", + f"/Fo{build_dir}\\", + *[str(path) for path in NATIVE_LIBRARY.bridge_sources()], + f"/Fe{output}", + "/link", + "/Brepro", + f"/LIBPATH:{libs_dir}", + f"{python_lib}.lib", + "delayimp.lib", + f"/DELAYLOAD:{python_lib}.dll", + ] + print(" ".join(cmd)) + subprocess.run(cmd, check=True) + + for suffix in (".lib", ".exp"): + output.with_suffix(suffix).unlink(missing_ok=True) + for path in build_dir.iterdir(): + path.unlink() + build_dir.rmdir() + print(f"Built {output}") + + +if __name__ == "__main__": + main() From 24035bef6a5345016b29ef48aea46608e937b1c2 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:04:14 +0200 Subject: [PATCH 06/28] Announce the VHDL to Python interface as a feature 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 --- docs/news.d/+python_bridge.feature.rst | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/news.d/+python_bridge.feature.rst b/docs/news.d/+python_bridge.feature.rst index c2247369c..6bd6fd802 100644 --- a/docs/news.d/+python_bridge.feature.rst +++ b/docs/news.d/+python_bridge.feature.rst @@ -1,13 +1,9 @@ -VHDL testbenches can now execute Python code and call Python functions, for -example NumPy reference models, through the ``python_pkg`` API: ``exec``, -``eval``/``eval_``, and ``call``/``arg``/``kwarg``/``to_call_str``, the -latter also taking ``std_logic`` and arbitrarily wide ``unsigned``/``signed`` -argument values and keyword argument groups combined with ``&``. Enable with -``add_vhdl_builtins()`` followed by ``add_python()``. The operations -implemented by the VUnit Python bridge are available on NVC and GHDL -(VHPIDIRECT) and on Questa/ModelSim (FLI): exchanging ``integer_array_t`` -values as NumPy arrays, boolean, std_logic and width-checked vector results, -executing files with ``exec_file`` and isolating models in named sessions. -Riviera-PRO/Active-HDL (VHPI) use a VHPI application, which ``add_python()`` -builds under the output path as well, and lack those operations. See +VHDL testbenches can now execute Python code and call Python functions +directly from the simulator: NumPy reference models, checkers, file access and +anything else the Python ecosystem offers. The VHDL API is ``python_pkg`` and +it is enabled with ``add_python()`` after ``add_vhdl_builtins()``. Values of +all the common VHDL types cross the boundary in both directions, as arguments +and as results. The API is available on NVC, GHDL and Questa/ModelSim through +the VUnit Python bridge, and on Riviera-PRO/Active-HDL through a VHPI +application, both of which ``add_python()`` builds under the output path. See :ref:`python_bridge`. From b9430da15d1705a68f3844388315bf7ea157f734 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:04:29 +0200 Subject: [PATCH 07/28] Use join() for the paths in the embedded Python example 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 --- examples/vhdl/embedded_python/tb_example.vhd | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index 48d3adbf1..49c812b7a 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -150,7 +150,7 @@ begin -- 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"); + import_module_from_file(join(tb_path(runner_cfg), "device_model.py"), "device_model"); end; begin @@ -314,7 +314,7 @@ begin 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"); + import_module_from_file(join(tb_path(runner_cfg), "bump.py"), "bumper"); check_equal(integer'(call("bumper.bump")), 1); check_equal(integer'(call("bumper.bump")), 2); @@ -588,7 +588,7 @@ begin -- integer_array_t is the VUnit type for data sets such as images and it is passed -- to Python as a NumPy array of the same shape. An array returned by Python -- becomes a new integer_array_t. The indexing matches: get(a, x, y) is a[y, x]. - import_module_from_file(tb_path(runner_cfg) & "image_model.py", "image_model"); + import_module_from_file(join(tb_path(runner_cfg), "image_model.py"), "image_model"); -- A 4 x 3 image with the column in the ones and the row in the tens image := new_2d(width => 4, height => 3, bit_width => 8, is_signed => false); @@ -765,6 +765,6 @@ begin -- A component whose behaviour is a Python function python_model_inst : entity work.python_model - generic map(model_file => tb_path(runner_cfg) & "python_model.py") + generic map(model_file => join(tb_path(runner_cfg), "python_model.py")) port map(x => model_x, y => model_y); end; From d7a4009a9ed0597c61be8c07061d201ba6d9cf1a Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:05:16 +0200 Subject: [PATCH 08/28] Name the typed subprograms and types std_ulogic 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 --- docs/python_bridge/user_guide.rst | 34 ++--- examples/vhdl/embedded_python/device_model.py | 4 +- examples/vhdl/embedded_python/run.py | 2 +- examples/vhdl/embedded_python/tb_example.vhd | 8 +- vunit/python_bridge/runtime.py | 28 ++-- .../vhdl/python/src/python_bridge_pkg.vhd.in | 2 +- .../vhdl/python/src/python_ffi_pkg_bridge.vhd | 6 +- vunit/vhdl/python/src/python_pkg.vhd | 140 +++++++++--------- vunit/vhdl/python/src/python_pkg_vhpi.vhd | 6 +- .../vhdl/python/test/tb_python_pkg_bridge.vhd | 42 +++--- .../vhdl/python/tools/generate_python_pkg.py | 36 ++--- vunit/vhdl/python/tools/python_pkg.vhd.in | 56 +++---- 12 files changed, 182 insertions(+), 182 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index ba02fd4c5..3bba8a00e 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -222,18 +222,18 @@ bridge, ``eval`` has more result types: * ``eval_boolean``, returning ``boolean``. Only ``bool``/``numpy.bool_`` is accepted. It also makes the result of ``eval`` usable as a condition: ``if eval("model.is_done()") then``. -* ``eval_std_logic``, returning ``std_logic``. -* ``eval_std_logic_vector``, returning an unconstrained ``std_logic_vector``. +* ``eval_std_ulogic``, returning ``std_ulogic``. +* ``eval_std_ulogic_vector``, returning an unconstrained ``std_ulogic_vector``. * ``eval_integer_array``, returning ``integer_array_t``, see :ref:`python_bridge:integer_array`. -``eval_boolean`` and ``eval_std_logic`` are aliased ``eval`` like the other -result types. ``eval_std_logic_vector`` and ``eval_integer_array`` are not, +``eval_boolean`` and ``eval_std_ulogic`` are aliased ``eval`` like the other +result types. ``eval_std_ulogic_vector`` and ``eval_integer_array`` are not, since that would make ``check_equal(eval("17"), 17)`` and ``length(eval("[1, 2]"))`` ambiguous: use their explicit names. -``std_logic_vector``, ``signed`` and ``unsigned`` results are also available -as the procedures ``eval_std_logic_vector``, ``eval_signed`` and +``std_ulogic_vector``, ``signed`` and ``unsigned`` results are also available +as the procedures ``eval_std_ulogic_vector``, ``eval_signed`` and ``eval_unsigned``, that take the result as an ``out`` parameter. The width is given by the actual, and a Python value that does not fit that width is an error. ``signed`` and ``unsigned`` results are only available in the @@ -241,10 +241,10 @@ procedure form, since a function cannot know the width of the result. .. code-block:: vhdl - variable byte : std_logic_vector(7 downto 0); + variable byte : std_ulogic_vector(7 downto 0); variable level : signed(15 downto 0); begin - eval_std_logic_vector("format(value, '08b')", byte); + eval_std_ulogic_vector("format(value, '08b')", byte); eval_signed("model.level()", level); call, arg, kwarg and to_call_str @@ -281,7 +281,7 @@ VHDL Python ``integer_vector`` ``list`` of ``int`` ``real_vector`` ``list`` of ``float`` ``integer_vector_ptr_t`` ``list`` of ``int`` -``std_logic`` ``bool``, 1 and H give ``True``, 0 and L give ``False`` +``std_ulogic`` ``bool``, 1 and H give ``True``, 0 and L give ``False`` ``unsigned`` ``int``, passed as ``arg_unsigned``/``kwarg_unsigned`` ``signed`` ``int``, passed as ``arg_signed``/``kwarg_signed`` ``integer_array_t`` NumPy array, not on Riviera-PRO/Active-HDL @@ -292,12 +292,12 @@ integer, so it is not limited to the range of a VHDL ``integer``. The two have names of their own rather than ``arg`` overloads, since a string literal belongs to every character array type and an overload would therefore make ``arg("hello")`` ambiguous. ``H`` and ``L`` are read as 1 and 0 in a -``std_logic``, ``unsigned`` or ``signed`` value; any other metavalue is an +``std_ulogic``, ``unsigned`` or ``signed`` value; any other metavalue is an error. An aggregate or a literal does not select an overload by itself and needs a qualified expression: ``arg(real_vector'(1.0, 2.0))``, -``arg(integer_vector'(1, 2, 3))``. There is no ``std_logic_vector`` value, +``arg(integer_vector'(1, 2, 3))``. There is no ``std_ulogic_vector`` value, which would be ambiguous for the same reason as ``unsigned``. Such a value is passed as a number or as the string of its characters: @@ -307,11 +307,11 @@ passed as a number or as the string of its characters: call("model.push", arg(to_string(slv))); On NVC, GHDL and Questa, ``call`` returns the same types as ``eval``: -``call_boolean``, ``call_std_logic``, ``call_std_logic_vector``, +``call_boolean``, ``call_std_ulogic``, ``call_std_ulogic_vector``, ``call_integer_array``, ``call_string``, ``call_real_vector`` and -``call_integer_vector_ptr``, plus the procedures ``call_std_logic_vector``, +``call_integer_vector_ptr``, plus the procedures ``call_std_ulogic_vector``, ``call_signed`` and ``call_unsigned`` taking the result as an ``out`` -parameter. All of the functions but ``call_std_logic_vector`` are aliased +parameter. All of the functions but ``call_std_ulogic_vector`` are aliased ``call``. ``to_call_str`` builds the Python call expression itself, as a string, which @@ -398,7 +398,7 @@ Type mapping * ``integer_vector`` ↔ ``list`` of ``int``. * ``real_vector`` ↔ ``list`` of ``float`` (``call_real_vector`` needs the bridge). * ``integer_vector_ptr_t`` ↔ ``list`` of ``int``. -* ``std_logic``/``std_logic_vector`` ↔ ``str``, one character per element +* ``std_ulogic``/``std_ulogic_vector`` ↔ ``str``, one character per element out of ``U X 0 1 Z W L H -``, left to right (results only, Python bridge). * ``signed``/``unsigned`` ↔ ``int`` (procedure results only, Python bridge). * ``integer_array_t`` ↔ ``numpy.ndarray`` (Python bridge), see @@ -538,7 +538,7 @@ installation change, so a run script needs nothing beyond :meth:`add_python() This application differs from the Python bridge in a few ways: only the default session exists, the operations implemented by the bridge -(``integer_array_t`` values, the ``boolean``, ``std_logic``, vector and +(``integer_array_t`` values, the ``boolean``, ``std_ulogic``, vector and ``integer_array_t`` results, ``exec_file``) report that they require NVC, GHDL or Questa, a Python error stops the simulation with the message printed by the application rather than through ``python_logger``, and ``real`` values outside @@ -550,7 +550,7 @@ a complete example covering all three simulator families. Its ``tb_example.vhd`` has a test case for each part of the API: ``exec`` and ``eval`` with the types they convert, calls with positional, keyword and keyword group arguments, a 20 register status dump, wide -``arg_unsigned``/``arg_signed`` and ``std_logic`` argument values, a 2-D +``arg_unsigned``/``arg_signed`` and ``std_ulogic`` argument values, a 2-D ``integer_array_t`` image transposed by NumPy, the result types of ``eval`` and ``call``, Python files executed with ``exec_file`` or imported with ``import_module_from_file``, two models loaded into a session each, and a diff --git a/examples/vhdl/embedded_python/device_model.py b/examples/vhdl/embedded_python/device_model.py index 64bfb6462..d7729ffb5 100644 --- a/examples/vhdl/embedded_python/device_model.py +++ b/examples/vhdl/embedded_python/device_model.py @@ -57,7 +57,7 @@ def status_word(): def status_bits(): """ - The status register as std_logic characters, for call_std_logic_vector. + The status register as std_ulogic characters, for call_std_ulogic_vector. """ return format(STATUS, "016b") @@ -71,7 +71,7 @@ def is_busy(): def parity(): """ - Odd parity of the status register as a std_logic character, for call_std_logic. + Odd parity of the status register as a std_ulogic character, for call_std_ulogic. """ return "1" if bin(STATUS).count("1") % 2 else "0" diff --git a/examples/vhdl/embedded_python/run.py b/examples/vhdl/embedded_python/run.py index 64592b7da..6d5de29ff 100644 --- a/examples/vhdl/embedded_python/run.py +++ b/examples/vhdl/embedded_python/run.py @@ -12,7 +12,7 @@ code and calling Python functions, for example NumPy/Matplotlib reference models, from a testbench. The test cases cover ``exec`` and ``eval`` with the types they convert, calls with positional, keyword and keyword group -arguments, wide ``unsigned``/``signed`` and ``std_logic`` argument values, an +arguments, wide ``unsigned``/``signed`` and ``std_ulogic`` argument values, an ``integer_array_t`` image shared with NumPy, Python files executed with ``exec_file`` or imported with ``import_module_from_file``, two models loaded into a session each, error reporting, and ``python_model``, a verification diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index 49c812b7a..4e4c3f65d 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -258,9 +258,9 @@ begin end if; 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")); - check_equal(call_std_logic_vector("device_model.status_bits"), std_logic_vector'(x"BEEF")); + -- A single bit and a vector of bits, std_ulogic characters on the Python side + check_equal(call_std_ulogic("device_model.parity"), '1', result("for the parity of the status")); + check_equal(call_std_ulogic_vector("device_model.status_bits"), std_ulogic_vector'(x"BEEF")); -- The procedure form takes the width of the result from the variable it writes to call_unsigned("device_model.status_word", status); @@ -406,7 +406,7 @@ begin elsif run("Test wide integers and status bits") then -- An unsigned or signed argument of any width becomes an exact Python integer and - -- is therefore not limited to the range of a VHDL integer, while a std_logic + -- is therefore not limited to the range of a VHDL integer, while a std_ulogic -- argument becomes a bool. These have names of their own rather than being arg -- overloads since a string literal belongs to every character array type. import_device_model; diff --git a/vunit/python_bridge/runtime.py b/vunit/python_bridge/runtime.py index f8a499a76..360b8fa7f 100644 --- a/vunit/python_bridge/runtime.py +++ b/vunit/python_bridge/runtime.py @@ -30,8 +30,8 @@ KIND_REAL = 1 KIND_BOOLEAN = 2 KIND_STRING = 3 -KIND_STD_LOGIC = 4 -KIND_STD_LOGIC_VECTOR = 5 +KIND_STD_ULOGIC = 4 +KIND_STD_ULOGIC_VECTOR = 5 KIND_SIGNED = 6 KIND_UNSIGNED = 7 KIND_INTEGER_ARRAY = 8 @@ -43,8 +43,8 @@ KIND_REAL: "real", KIND_BOOLEAN: "boolean", KIND_STRING: "string", - KIND_STD_LOGIC: "std_logic", - KIND_STD_LOGIC_VECTOR: "std_logic_vector", + KIND_STD_ULOGIC: "std_ulogic", + KIND_STD_ULOGIC_VECTOR: "std_ulogic_vector", KIND_SIGNED: "signed", KIND_UNSIGNED: "unsigned", KIND_INTEGER_ARRAY: "integer_array_t", @@ -52,7 +52,7 @@ KIND_REAL_VECTOR: "real_vector", } -STD_LOGIC_VALUES = frozenset("UX01ZWLH-") +STD_ULOGIC_VALUES = frozenset("UX01ZWLH-") INTEGER_LOW = -(2**31) INTEGER_HIGH = 2**31 - 1 TEXT_ENCODING = "utf-8" @@ -372,7 +372,7 @@ def evaluate(self, text, kind, width): Evaluate a Python expression in the session namespace and convert the value to the VHDL type given by kind. - :param width: Width of a std_logic_vector/signed/unsigned result, -1 if not given by VHDL. + :param width: Width of a std_ulogic_vector/signed/unsigned result, -1 if not given by VHDL. :returns: (integer, real, data bytes, metadata tuple) """ self._result = _NO_VALUE @@ -464,7 +464,7 @@ def convert_result(self, kind, width): """ Convert the value of the last evaluation to the VHDL type given by kind. - :param width: Width of a std_logic_vector/signed/unsigned result, -1 if not given by VHDL. + :param width: Width of a std_ulogic_vector/signed/unsigned result, -1 if not given by VHDL. :returns: (integer, real, data bytes, metadata tuple) """ value = self._result @@ -479,8 +479,8 @@ def convert_result(self, kind, width): KIND_REAL: self._real_result, KIND_BOOLEAN: self._boolean_result, KIND_STRING: self._string_result, - KIND_STD_LOGIC: self._std_logic_result, - KIND_STD_LOGIC_VECTOR: self._std_logic_vector_result, + KIND_STD_ULOGIC: self._std_ulogic_result, + KIND_STD_ULOGIC_VECTOR: self._std_ulogic_vector_result, KIND_SIGNED: self._bits_result, KIND_UNSIGNED: self._bits_result, KIND_INTEGER_VECTOR: self._integer_vector_result, @@ -536,23 +536,23 @@ def _string_result(kind, value, _width): return (0, 0.0, data, (len(data),)) @staticmethod - def _std_logic_result(kind, value, _width): + def _std_ulogic_result(kind, value, _width): """ Convert a one character str result. """ - if not isinstance(value, str) or len(value) != 1 or value not in STD_LOGIC_VALUES: + if not isinstance(value, str) or len(value) != 1 or value not in STD_ULOGIC_VALUES: raise _type_error(kind, value, "a one character str, one of 'UX01ZWLH-'") return (0, 0.0, value.encode("ascii"), (1,)) @staticmethod - def _std_logic_vector_result(kind, value, width): + def _std_ulogic_vector_result(kind, value, width): """ Convert a str result, one character per element. """ - if not isinstance(value, str) or not STD_LOGIC_VALUES.issuperset(value): + if not isinstance(value, str) or not STD_ULOGIC_VALUES.issuperset(value): raise _type_error(kind, value, "a str of the characters 'UX01ZWLH-'") if width >= 0 and len(value) != width: - raise ValueError(f"Got {len(value)} std_logic values but the VHDL result has length {width}") + raise ValueError(f"Got {len(value)} std_ulogic values but the VHDL result has length {width}") return (0, 0.0, value.encode("ascii"), (len(value),)) def _bits_result(self, kind, value, width): diff --git a/vunit/vhdl/python/src/python_bridge_pkg.vhd.in b/vunit/vhdl/python/src/python_bridge_pkg.vhd.in index d25e4b0de..46bb455fe 100644 --- a/vunit/vhdl/python/src/python_bridge_pkg.vhd.in +++ b/vunit/vhdl/python/src/python_bridge_pkg.vhd.in @@ -47,7 +47,7 @@ package python_bridge_pkg is attribute foreign of vpy_execute : function is "{foreign:vpy_execute}"; -- Evaluate the buffer as a Python expression and convert the value to the - -- VHDL type given by kind. width is the length of a std_logic_vector, + -- VHDL type given by kind. width is the length of a std_ulogic_vector, -- signed or unsigned result, -1 when it is not known. impure function vpy_eval(kind, width : integer) return integer; attribute foreign of vpy_eval : function is "{foreign:vpy_eval}"; diff --git a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd index 8cc83dd77..fce968a3e 100644 --- a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd +++ b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd @@ -78,8 +78,8 @@ package python_ffi_pkg is constant p_kind_real : integer := 1; constant p_kind_boolean : integer := 2; constant p_kind_string : integer := 3; - constant p_kind_std_logic : integer := 4; - constant p_kind_std_logic_vector : integer := 5; + constant p_kind_std_ulogic : integer := 4; + constant p_kind_std_ulogic_vector : integer := 5; constant p_kind_signed : integer := 6; constant p_kind_unsigned : integer := 7; constant p_kind_integer_array : integer := 8; @@ -119,7 +119,7 @@ package python_ffi_pkg is ) return boolean; -- Evaluate expr and convert the value to the VHDL type given by kind. width - -- is the length of a std_logic_vector, signed or unsigned result, -1 when + -- is the length of a std_ulogic_vector, signed or unsigned result, -1 when -- it is not known. impure function p_eval( expr : string; diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index a28a089a3..5851ebad7 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -97,12 +97,12 @@ package python_pkg is -- More argument values of call ----------------------------------------------------------------------------- -- real_vector and integer_vector_ptr_t values become Python lists, a - -- std_logic value becomes True or False, and an unsigned or signed value of + -- std_ulogic value becomes True or False, and an unsigned or signed value of -- any width becomes a Python integer written as a hexadecimal literal, which -- is therefore not limited to the range of a VHDL integer. -- -- An aggregate or a literal needs a qualified expression to select the - -- overload, for example arg(real_vector'(1.0, 2.0)). A std_logic_vector is + -- overload, for example arg(real_vector'(1.0, 2.0)). A std_ulogic_vector is -- passed as a string, arg(to_string(slv)), or as a number, -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of -- their own since an overload would make a string literal argument, @@ -113,8 +113,8 @@ package python_pkg is function kwarg(kw : string; value : real_vector) return arg_t; impure function arg(value : integer_vector_ptr_t) return arg_t; impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t; - impure function arg(value : std_logic) return arg_t; - impure function kwarg(kw : string; value : std_logic) return arg_t; + impure function arg(value : std_ulogic) return arg_t; + impure function kwarg(kw : string; value : std_ulogic) return arg_t; impure function arg_unsigned(value : unsigned) return arg_t; impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t; impure function arg_signed(value : signed) return arg_t; @@ -153,9 +153,9 @@ package python_pkg is impure function kwarg(kw : string; value : integer_array_t) return arg_t; ----------------------------------------------------------------------------- - -- Results of eval: boolean, std_logic, vectors and arrays + -- Results of eval: boolean, std_ulogic, vectors and arrays ----------------------------------------------------------------------------- - -- std_logic_vector and integer_array_t results are only available under + -- std_ulogic_vector and integer_array_t results are only available under -- their explicit names, not as eval overloads, which keeps -- check_equal(eval("17"), 17) and length(eval("[1, 2]")) unambiguous. -- signed and unsigned results are only available in the procedure form @@ -165,21 +165,21 @@ package python_pkg is ) return boolean; alias eval is eval_boolean[string, python_session_t return boolean]; - impure function eval_std_logic( + impure function eval_std_ulogic( expr : string; session : python_session_t := default_session - ) return std_logic; - alias eval is eval_std_logic[string, python_session_t return std_logic]; + ) return std_ulogic; + alias eval is eval_std_ulogic[string, python_session_t return std_ulogic]; - impure function eval_std_logic_vector( + impure function eval_std_ulogic_vector( expr : string; session : python_session_t := default_session - ) return std_logic_vector; + ) return std_ulogic_vector; impure function eval_integer_array( expr : string; session : python_session_t := default_session ) return integer_array_t; - procedure eval_std_logic_vector( - expr : string; result : out std_logic_vector; session : python_session_t := default_session + procedure eval_std_ulogic_vector( + expr : string; result : out std_ulogic_vector; session : python_session_t := default_session ); procedure eval_signed( expr : string; result : out signed; session : python_session_t := default_session @@ -189,7 +189,7 @@ package python_pkg is ); ----------------------------------------------------------------------------- - -- Results of call: string, boolean, std_logic, vectors and arrays + -- Results of call: string, boolean, std_ulogic, vectors and arrays ----------------------------------------------------------------------------- impure function call_real_vector( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; @@ -219,17 +219,17 @@ package python_pkg is alias call is call_boolean[ string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return boolean]; - impure function call_std_logic( + impure function call_std_ulogic( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session - ) return std_logic; - alias call is call_std_logic[ - string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return std_logic]; + ) return std_ulogic; + alias call is call_std_ulogic[ + string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return std_ulogic]; - impure function call_std_logic_vector( + impure function call_std_ulogic_vector( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session - ) return std_logic_vector; + ) return std_ulogic_vector; impure function call_integer_array( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; @@ -238,8 +238,8 @@ package python_pkg is alias call is call_integer_array[ string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return integer_array_t]; - procedure call_std_logic_vector( - identifier : string; result : out std_logic_vector; + procedure call_std_ulogic_vector( + identifier : string; result : out std_ulogic_vector; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session ); @@ -545,7 +545,7 @@ package body python_pkg is return to_py_list_str(value); end; - impure function p_arg_value(value : std_logic; operation : string) return string is + impure function p_arg_value(value : std_ulogic; operation : string) return string is begin if to_x01(value) = '1' then return "True"; @@ -554,13 +554,13 @@ package body python_pkg is end if; failure( python_logger, - operation & " cannot convert " & std_logic'image(value) & "; expected '0', '1', 'L' or 'H'" + operation & " cannot convert " & std_ulogic'image(value) & "; expected '0', '1', 'L' or 'H'" ); return ""; end; -- True when the vector has an element other than 0, 1, L and H - function p_has_metavalue(value : std_logic_vector) return boolean is + function p_has_metavalue(value : std_ulogic_vector) return boolean is begin for idx in value'range loop if to_x01(value(idx)) = 'X' then @@ -572,10 +572,10 @@ package body python_pkg is -- The Python hexadecimal literal of the magnitude given by the bits, for -- example 0x1F or -0x80. A null range gives 0x0. - function p_to_hex_literal(bits : std_logic_vector; negative : boolean) return string is + function p_to_hex_literal(bits : std_ulogic_vector; negative : boolean) return string is constant hex_digits : string(1 to 16) := "0123456789ABCDEF"; constant padding : natural := (4 - bits'length mod 4) mod 4; - variable padded : std_logic_vector(bits'length + padding - 1 downto 0) := (others => '0'); + variable padded : std_ulogic_vector(bits'length + padding - 1 downto 0) := (others => '0'); variable result : line; variable digit : natural; variable leading : boolean := true; @@ -608,39 +608,39 @@ package body python_pkg is end; -- The error reported for a vector value that cannot be converted - function p_metavalue_error(value : std_logic_vector; operation : string) return string is + function p_metavalue_error(value : std_ulogic_vector; operation : string) return string is begin return operation & " cannot convert """ & to_string(value) & """; the value has metavalues"; end; impure function p_arg_value(value : unsigned; operation : string) return string is begin - if p_has_metavalue(std_logic_vector(value)) then - failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + if p_has_metavalue(std_ulogic_vector(value)) then + failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; - return p_to_hex_literal(to_x01(std_logic_vector(value)), false); + return p_to_hex_literal(to_x01(std_ulogic_vector(value)), false); end; impure function p_arg_value(value : signed; operation : string) return string is variable folded : signed(value'length - 1 downto 0); variable magnitude : signed(value'length downto 0); begin - if p_has_metavalue(std_logic_vector(value)) then - failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + if p_has_metavalue(std_ulogic_vector(value)) then + failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; if value'length = 0 then return "0x0"; end if; - folded := signed(to_x01(std_logic_vector(value))); + folded := signed(to_x01(std_ulogic_vector(value))); if folded(folded'left) = '0' then - return p_to_hex_literal(std_logic_vector(folded), false); + return p_to_hex_literal(std_ulogic_vector(folded), false); end if; -- Resizing before negating keeps the magnitude of signed'low representable magnitude := -resize(folded, value'length + 1); - return p_to_hex_literal(std_logic_vector(magnitude), true); + return p_to_hex_literal(std_ulogic_vector(magnitude), true); end; function arg(value : real_vector) return arg_t is @@ -679,7 +679,7 @@ package body python_pkg is return (kw, text); end; - impure function arg(value : std_logic) return arg_t is + impure function arg(value : std_ulogic) return arg_t is constant text : string := p_arg_value(value, "arg"); begin if text = "" then @@ -688,7 +688,7 @@ package body python_pkg is return (p_positional_arg, text); end; - impure function kwarg(kw : string; value : std_logic) return arg_t is + impure function kwarg(kw : string; value : std_ulogic) return arg_t is constant text : string := p_arg_value(value, "kwarg"); begin if text = "" then @@ -781,14 +781,14 @@ package body python_pkg is ----------------------------------------------------------------------------- -- Operations implemented by the Python bridge (NVC, GHDL and Questa) ----------------------------------------------------------------------------- - -- Python represents std_logic values by these characters - constant p_std_logic_characters : string(1 to 9) := "UX01ZWLH-"; + -- Python represents std_ulogic values by these characters + constant p_std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; - function p_to_std_logic(value : character) return std_logic is + function p_to_std_ulogic(value : character) return std_ulogic is begin - for idx in p_std_logic_characters'range loop - if p_std_logic_characters(idx) = value then - return std_logic'val(idx - 1); + for idx in p_std_ulogic_characters'range loop + if p_std_ulogic_characters(idx) = value then + return std_ulogic'val(idx - 1); end if; end loop; -- The bridge only returns the characters above @@ -796,12 +796,12 @@ package body python_pkg is end; -- The elements of the characters, the first character becoming the leftmost element - function p_to_std_logic_vector(value : string) return std_logic_vector is + function p_to_std_ulogic_vector(value : string) return std_ulogic_vector is alias normalized : string(1 to value'length) is value; - variable result : std_logic_vector(value'length - 1 downto 0); + variable result : std_ulogic_vector(value'length - 1 downto 0); begin for idx in normalized'range loop - result(value'length - idx) := p_to_std_logic(normalized(idx)); + result(value'length - idx) := p_to_std_ulogic(normalized(idx)); end loop; return result; end; @@ -899,22 +899,22 @@ package body python_pkg is return false; end; - impure function eval_std_logic( + impure function eval_std_ulogic( expr : string; session : python_session_t := default_session - ) return std_logic is + ) return std_ulogic is begin - if p_eval(expr, p_kind_std_logic, -1, p_eval_operation(expr, session), session) then - return p_to_std_logic(p_result_string(1)); + if p_eval(expr, p_kind_std_ulogic, -1, p_eval_operation(expr, session), session) then + return p_to_std_ulogic(p_result_string(1)); end if; return 'U'; end; - impure function eval_std_logic_vector( + impure function eval_std_ulogic_vector( expr : string; session : python_session_t := default_session - ) return std_logic_vector is + ) return std_ulogic_vector is begin - if p_eval(expr, p_kind_std_logic_vector, -1, p_eval_operation(expr, session), session) then - return p_to_std_logic_vector(p_result_string); + if p_eval(expr, p_kind_std_ulogic_vector, -1, p_eval_operation(expr, session), session) then + return p_to_std_ulogic_vector(p_result_string); end if; return ""; end; @@ -929,12 +929,12 @@ package body python_pkg is return null_integer_array; end; - procedure eval_std_logic_vector( - expr : string; result : out std_logic_vector; session : python_session_t := default_session + procedure eval_std_ulogic_vector( + expr : string; result : out std_ulogic_vector; session : python_session_t := default_session ) is begin - if p_eval(expr, p_kind_std_logic_vector, result'length, p_eval_operation(expr, session), session) then - result := p_to_std_logic_vector(p_result_string); + if p_eval(expr, p_kind_std_ulogic_vector, result'length, p_eval_operation(expr, session), session) then + result := p_to_std_ulogic_vector(p_result_string); end if; end; @@ -943,7 +943,7 @@ package body python_pkg is ) is begin if p_eval(expr, p_kind_signed, result'length, p_eval_operation(expr, session), session) then - result := signed(p_to_std_logic_vector(p_result_string)); + result := signed(p_to_std_ulogic_vector(p_result_string)); end if; end; @@ -952,7 +952,7 @@ package body python_pkg is ) is begin if p_eval(expr, p_kind_unsigned, result'length, p_eval_operation(expr, session), session) then - result := unsigned(p_to_std_logic_vector(p_result_string)); + result := unsigned(p_to_std_ulogic_vector(p_result_string)); end if; end; @@ -999,22 +999,22 @@ package body python_pkg is ); end; - impure function call_std_logic( + impure function call_std_ulogic( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session - ) return std_logic is + ) return std_ulogic is begin - return eval_std_logic( + return eval_std_ulogic( p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; - impure function call_std_logic_vector( + impure function call_std_ulogic_vector( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session - ) return std_logic_vector is + ) return std_ulogic_vector is begin - return eval_std_logic_vector( + return eval_std_ulogic_vector( p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -1029,13 +1029,13 @@ package body python_pkg is ); end; - procedure call_std_logic_vector( - identifier : string; result : out std_logic_vector; + procedure call_std_ulogic_vector( + identifier : string; result : out std_ulogic_vector; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session ) is begin - eval_std_logic_vector( + eval_std_ulogic_vector( p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session ); end; diff --git a/vunit/vhdl/python/src/python_pkg_vhpi.vhd b/vunit/vhdl/python/src/python_pkg_vhpi.vhd index 4d6f3a606..4f15255ae 100644 --- a/vunit/vhdl/python/src/python_pkg_vhpi.vhd +++ b/vunit/vhdl/python/src/python_pkg_vhpi.vhd @@ -73,8 +73,8 @@ package python_ffi_pkg is constant p_kind_real : integer := 1; constant p_kind_boolean : integer := 2; constant p_kind_string : integer := 3; - constant p_kind_std_logic : integer := 4; - constant p_kind_std_logic_vector : integer := 5; + constant p_kind_std_ulogic : integer := 4; + constant p_kind_std_ulogic_vector : integer := 5; constant p_kind_signed : integer := 6; constant p_kind_unsigned : integer := 7; constant p_kind_integer_array : integer := 8; @@ -90,7 +90,7 @@ package python_ffi_pkg is ) return boolean; -- Evaluate expr and convert the value to the VHDL type given by kind. width - -- is the length of a std_logic_vector, signed or unsigned result, -1 when + -- is the length of a std_ulogic_vector, signed or unsigned result, -1 when -- it is not known. impure function p_eval( expr : string; diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd index b97b8c445..bc1746183 100644 --- a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -21,7 +21,7 @@ entity tb_python_pkg_bridge is end entity; architecture tb of tb_python_pkg_bridge is - constant std_logic_characters : string(1 to 9) := "UX01ZWLH-"; + constant std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; constant group_error : string := "Only keyword arguments can be combined with & into a keyword argument group"; begin main : process @@ -29,10 +29,10 @@ begin constant fixed_point : python_session_t := "fixed_point"; variable arr, arr_b, result : integer_array_t; variable ptr : integer_vector_ptr_t; - variable slv4 : std_logic_vector(3 downto 0); - variable slv9_desc : std_logic_vector(8 downto 0) := "UX01ZWLH-"; - variable slv9_asc : std_logic_vector(0 to 8) := "UX01ZWLH-"; - variable result_vec9 : std_logic_vector(8 downto 0); + variable slv4 : std_ulogic_vector(3 downto 0); + variable slv9_desc : std_ulogic_vector(8 downto 0) := "UX01ZWLH-"; + variable slv9_asc : std_ulogic_vector(0 to 8) := "UX01ZWLH-"; + variable result_vec9 : std_ulogic_vector(8 downto 0); variable s8 : signed(7 downto 0); variable u8 : unsigned(7 downto 0); variable u1 : unsigned(0 downto 0) := "1"; @@ -300,23 +300,23 @@ begin check_equal(call_string("identity", arg(string'("hello"))), string'("hello")); check_equal(call_string("identity", arg(string'("café å"))), string'("café å")); - elsif run("Test std_logic round trip of all 9 states") then + elsif run("Test std_ulogic round trip of all 9 states") then exec("def identity(x):" + " return x"); - for idx in std_logic_characters'range loop + for idx in std_ulogic_characters'range loop check_equal( - call_std_logic("identity", arg(string'(1 => std_logic_characters(idx)))), - std_logic'val(idx - 1) + call_std_ulogic("identity", arg(string'(1 => std_ulogic_characters(idx)))), + std_ulogic'val(idx - 1) ); end loop; - elsif run("Test std_logic_vector round trip with descending range") then + elsif run("Test std_ulogic_vector round trip with descending range") then exec("def identity(x):" + " return x"); - result_vec9 := call_std_logic_vector("identity", arg(to_string(slv9_desc))); + result_vec9 := call_std_ulogic_vector("identity", arg(to_string(slv9_desc))); check_equal(result_vec9, slv9_desc); - elsif run("Test std_logic_vector round trip with ascending range") then + elsif run("Test std_ulogic_vector round trip with ascending range") then exec("def identity(x):" + " return x"); - call_std_logic_vector("identity", result_vec9, arg(to_string(slv9_asc))); + call_std_ulogic_vector("identity", result_vec9, arg(to_string(slv9_asc))); check_equal(result_vec9, slv9_asc); elsif run("Test signed and unsigned procedure results including exact bounds") then @@ -448,14 +448,14 @@ begin ); unmock(python_logger); - elsif run("Test that a std_logic_vector length mismatch in a procedure result fails") then + elsif run("Test that a std_ulogic_vector length mismatch in a procedure result fails") then exec("def wrong_length_bits():" + " return '01011'"); mock(python_logger, failure); - call_std_logic_vector("wrong_length_bits", slv4); + call_std_ulogic_vector("wrong_length_bits", slv4); check_only_log( python_logger, "eval(""wrong_length_bits()"") failed:" & LF & - "ValueError: Got 5 std_logic values but the VHDL result has length 4", + "ValueError: Got 5 std_ulogic values but the VHDL result has length 4", failure ); unmock(python_logger); @@ -489,13 +489,13 @@ begin check_equal(call_string("describe", arg(ptr)), "[-1, 0, 1]"); check_equal(call_string("describe", kwarg("v", ptr)), "v=[-1, 0, 1]"); - elsif run("Test std_logic_vector, signed and unsigned arguments") then + elsif run("Test std_ulogic_vector, signed and unsigned arguments") then -- There are no arg overloads for these types, which would make a -- string literal argument ambiguous. They are passed as a string, as -- an integer or through arg_unsigned/arg_signed instead. define_describe; check_equal(call_string("describe", arg(to_string(slv9_desc))), "'UX01ZWLH-'"); - check_equal(call_string("describe", kwarg("v", to_string(std_logic_vector'("10XZ")))), "v='10XZ'"); + check_equal(call_string("describe", kwarg("v", to_string(std_ulogic_vector'("10XZ")))), "v='10XZ'"); s8 := to_signed(-128, 8); u8 := to_unsigned(255, 8); check_equal(call_string("describe", arg(to_integer(s8)), arg(to_integer(u8))), "-128, 255"); @@ -618,7 +618,7 @@ begin unmock(python_logger); --------------------------------------------------------------------- - -- Wide and std_logic argument values + -- Wide and std_ulogic argument values --------------------------------------------------------------------- elsif run("Test unsigned arguments of any width") then define_as_str; @@ -673,12 +673,12 @@ begin ); unmock(python_logger); - elsif run("Test std_logic arguments") then + elsif run("Test std_ulogic arguments") then define_describe; check_equal(call_string("describe", arg('1'), arg('0')), "True, False"); check_equal(call_string("describe", kwarg("v", '1'), kwarg("w", 'L')), "v=True, w=False"); - elsif run("Test that a metavalue std_logic argument fails") then + elsif run("Test that a metavalue std_ulogic argument fails") then define_describe; mock(python_logger, failure); check_equal(call_string("describe", arg('X')), ""); diff --git a/vunit/vhdl/python/tools/generate_python_pkg.py b/vunit/vhdl/python/tools/generate_python_pkg.py index d13e96fe7..770433e04 100644 --- a/vunit/vhdl/python/tools/generate_python_pkg.py +++ b/vunit/vhdl/python/tools/generate_python_pkg.py @@ -46,14 +46,14 @@ # The unsigned and signed values are passed to typed names, arg_unsigned and # arg_signed, on purpose: plain overloads would make a string literal # argument, arg("Hello"), ambiguous since a string literal belongs to every -# character array type. std_logic needs no such name, being a scalar, and -# std_logic_vector is deliberately not among the types at all. Such values +# character array type. std_ulogic needs no such name, being a scalar, and +# std_ulogic_vector is deliberately not among the types at all. Such values # are passed as arg(to_string(slv)) and arg_unsigned(unsigned(slv)). ARG_VALUES = [ dict(vhdl="real_vector", impure=False, suffix="", bridge=False), dict(vhdl="integer_vector_ptr_t", impure=True, suffix="", bridge=False), dict(vhdl="integer_array_t", impure=True, suffix="", bridge=True), - dict(vhdl="std_logic", impure=True, suffix="", bridge=False), + dict(vhdl="std_ulogic", impure=True, suffix="", bridge=False), dict(vhdl="unsigned", impure=True, suffix="_unsigned", bridge=False), dict(vhdl="signed", impure=True, suffix="_signed", bridge=False), ] @@ -121,24 +121,24 @@ hand_written_call=False, ), dict( - name="std_logic", - vhdl="std_logic", - kind="p_kind_std_logic", - value="p_to_std_logic(p_result_string(1))", + name="std_ulogic", + vhdl="std_ulogic", + kind="p_kind_std_ulogic", + value="p_to_std_ulogic(p_result_string(1))", default="'U'", hand_written_eval=False, hand_written_call=False, ), dict( - name="std_logic_vector", - vhdl="std_logic_vector", - kind="p_kind_std_logic_vector", - value="p_to_std_logic_vector(p_result_string)", + name="std_ulogic_vector", + vhdl="std_ulogic_vector", + kind="p_kind_std_ulogic_vector", + value="p_to_std_ulogic_vector(p_result_string)", default='""', hand_written_eval=False, hand_written_call=False, # No eval/call alias: check_equal(eval("17"), 17) would otherwise be ambiguous - # with check_equal(std_logic_vector, natural). Use the explicit names. + # with check_equal(std_ulogic_vector, natural). Use the explicit names. alias_eval=False, alias_call=False, ), @@ -159,22 +159,22 @@ # Result types whose width is given by the actual of the result parameter PROCEDURE_RESULTS = [ dict( - name="std_logic_vector", - vhdl="std_logic_vector", - kind="p_kind_std_logic_vector", - value="p_to_std_logic_vector(p_result_string)", + name="std_ulogic_vector", + vhdl="std_ulogic_vector", + kind="p_kind_std_ulogic_vector", + value="p_to_std_ulogic_vector(p_result_string)", ), dict( name="signed", vhdl="signed", kind="p_kind_signed", - value="signed(p_to_std_logic_vector(p_result_string))", + value="signed(p_to_std_ulogic_vector(p_result_string))", ), dict( name="unsigned", vhdl="unsigned", kind="p_kind_unsigned", - value="unsigned(p_to_std_logic_vector(p_result_string))", + value="unsigned(p_to_std_ulogic_vector(p_result_string))", ), ] diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index efa25e9df..5ca09730f 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -97,12 +97,12 @@ package python_pkg is -- More argument values of call ----------------------------------------------------------------------------- -- real_vector and integer_vector_ptr_t values become Python lists, a - -- std_logic value becomes True or False, and an unsigned or signed value of + -- std_ulogic value becomes True or False, and an unsigned or signed value of -- any width becomes a Python integer written as a hexadecimal literal, which -- is therefore not limited to the range of a VHDL integer. -- -- An aggregate or a literal needs a qualified expression to select the - -- overload, for example arg(real_vector'(1.0, 2.0)). A std_logic_vector is + -- overload, for example arg(real_vector'(1.0, 2.0)). A std_ulogic_vector is -- passed as a string, arg(to_string(slv)), or as a number, -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of -- their own since an overload would make a string literal argument, @@ -143,9 +143,9 @@ $value_arg_declarations $arg_declarations ----------------------------------------------------------------------------- - -- Results of eval: boolean, std_logic, vectors and arrays + -- Results of eval: boolean, std_ulogic, vectors and arrays ----------------------------------------------------------------------------- - -- std_logic_vector and integer_array_t results are only available under + -- std_ulogic_vector and integer_array_t results are only available under -- their explicit names, not as eval overloads, which keeps -- check_equal(eval("17"), 17) and length(eval("[1, 2]")) unambiguous. -- signed and unsigned results are only available in the procedure form @@ -153,7 +153,7 @@ $arg_declarations $eval_declarations ----------------------------------------------------------------------------- - -- Results of call: string, boolean, std_logic, vectors and arrays + -- Results of call: string, boolean, std_ulogic, vectors and arrays ----------------------------------------------------------------------------- $call_declarations @@ -448,7 +448,7 @@ package body python_pkg is return to_py_list_str(value); end; - impure function p_arg_value(value : std_logic; operation : string) return string is + impure function p_arg_value(value : std_ulogic; operation : string) return string is begin if to_x01(value) = '1' then return "True"; @@ -457,13 +457,13 @@ package body python_pkg is end if; failure( python_logger, - operation & " cannot convert " & std_logic'image(value) & "; expected '0', '1', 'L' or 'H'" + operation & " cannot convert " & std_ulogic'image(value) & "; expected '0', '1', 'L' or 'H'" ); return ""; end; -- True when the vector has an element other than 0, 1, L and H - function p_has_metavalue(value : std_logic_vector) return boolean is + function p_has_metavalue(value : std_ulogic_vector) return boolean is begin for idx in value'range loop if to_x01(value(idx)) = 'X' then @@ -475,10 +475,10 @@ package body python_pkg is -- The Python hexadecimal literal of the magnitude given by the bits, for -- example 0x1F or -0x80. A null range gives 0x0. - function p_to_hex_literal(bits : std_logic_vector; negative : boolean) return string is + function p_to_hex_literal(bits : std_ulogic_vector; negative : boolean) return string is constant hex_digits : string(1 to 16) := "0123456789ABCDEF"; constant padding : natural := (4 - bits'length mod 4) mod 4; - variable padded : std_logic_vector(bits'length + padding - 1 downto 0) := (others => '0'); + variable padded : std_ulogic_vector(bits'length + padding - 1 downto 0) := (others => '0'); variable result : line; variable digit : natural; variable leading : boolean := true; @@ -511,39 +511,39 @@ package body python_pkg is end; -- The error reported for a vector value that cannot be converted - function p_metavalue_error(value : std_logic_vector; operation : string) return string is + function p_metavalue_error(value : std_ulogic_vector; operation : string) return string is begin return operation & " cannot convert """ & to_string(value) & """; the value has metavalues"; end; impure function p_arg_value(value : unsigned; operation : string) return string is begin - if p_has_metavalue(std_logic_vector(value)) then - failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + if p_has_metavalue(std_ulogic_vector(value)) then + failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; - return p_to_hex_literal(to_x01(std_logic_vector(value)), false); + return p_to_hex_literal(to_x01(std_ulogic_vector(value)), false); end; impure function p_arg_value(value : signed; operation : string) return string is variable folded : signed(value'length - 1 downto 0); variable magnitude : signed(value'length downto 0); begin - if p_has_metavalue(std_logic_vector(value)) then - failure(python_logger, p_metavalue_error(std_logic_vector(value), operation)); + if p_has_metavalue(std_ulogic_vector(value)) then + failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; if value'length = 0 then return "0x0"; end if; - folded := signed(to_x01(std_logic_vector(value))); + folded := signed(to_x01(std_ulogic_vector(value))); if folded(folded'left) = '0' then - return p_to_hex_literal(std_logic_vector(folded), false); + return p_to_hex_literal(std_ulogic_vector(folded), false); end if; -- Resizing before negating keeps the magnitude of signed'low representable magnitude := -resize(folded, value'length + 1); - return p_to_hex_literal(std_logic_vector(magnitude), true); + return p_to_hex_literal(std_ulogic_vector(magnitude), true); end; $value_arg_functions @@ -596,14 +596,14 @@ $value_arg_functions ----------------------------------------------------------------------------- -- Operations implemented by the Python bridge (NVC, GHDL and Questa) ----------------------------------------------------------------------------- - -- Python represents std_logic values by these characters - constant p_std_logic_characters : string(1 to 9) := "UX01ZWLH-"; + -- Python represents std_ulogic values by these characters + constant p_std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; - function p_to_std_logic(value : character) return std_logic is + function p_to_std_ulogic(value : character) return std_ulogic is begin - for idx in p_std_logic_characters'range loop - if p_std_logic_characters(idx) = value then - return std_logic'val(idx - 1); + for idx in p_std_ulogic_characters'range loop + if p_std_ulogic_characters(idx) = value then + return std_ulogic'val(idx - 1); end if; end loop; -- The bridge only returns the characters above @@ -611,12 +611,12 @@ $value_arg_functions end; -- The elements of the characters, the first character becoming the leftmost element - function p_to_std_logic_vector(value : string) return std_logic_vector is + function p_to_std_ulogic_vector(value : string) return std_ulogic_vector is alias normalized : string(1 to value'length) is value; - variable result : std_logic_vector(value'length - 1 downto 0); + variable result : std_ulogic_vector(value'length - 1 downto 0); begin for idx in normalized'range loop - result(value'length - idx) := p_to_std_logic(normalized(idx)); + result(value'length - idx) := p_to_std_ulogic(normalized(idx)); end loop; return result; end; From 4ce146ee3dd8fedc259b30ee98d9460975a2b266 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:13:58 +0200 Subject: [PATCH 09/28] Resolve a relative exec_file name from the testbench directory 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 --- docs/python_bridge/user_guide.rst | 13 +++----- examples/vhdl/embedded_python/tb_example.vhd | 3 +- tests/unit/test_python_bridge.py | 20 ++++++------ vunit/python_bridge/bridge.py | 12 +++---- vunit/python_bridge/native/bridge.h | 10 +++--- vunit/python_bridge/native/config.c | 7 ++-- vunit/python_bridge/native/interpreter.c | 5 +-- vunit/python_bridge/runtime.py | 24 +++++++------- vunit/vhdl/python/src/python_pkg.vhd | 32 +++++++++++++++++-- .../vhdl/python/test/tb_python_pkg_bridge.vhd | 31 +++++++++--------- vunit/vhdl/python/tools/python_pkg.vhd.in | 32 +++++++++++++++++-- 11 files changed, 122 insertions(+), 67 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 3bba8a00e..0af7139a0 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -376,7 +376,7 @@ what ``import_run_script`` uses internally: .. code-block:: vhdl - import_module_from_file(tb_path(runner_cfg) & "reference_model.py", "reference_model"); + import_module_from_file(join(tb_path(runner_cfg), "reference_model.py"), "reference_model"); exec("reference_model.configure(gain=4)"); ``to_py_list_str`` converts an ``integer_vector``, ``integer_vector_ptr_t`` or @@ -491,13 +491,10 @@ while it executes so that it can import sibling modules without setting ``PYTHONPATH``. Imports are therefore expected at the top level of the file. Executing the same file twice executes it twice. -A relative file name is relative to the directory of the VUnit run script -(the script started by ``python``), independently of the simulator and of -the current working directory. That directory is also the run script's own -first entry of ``sys.path``. Absolute paths work too, for example -``tb_path(runner_cfg) & "model.py"`` for a file next to the testbench. -Like the other operations, it takes an optional trailing -:ref:`session ` parameter. +A relative file name is relative to the directory of the testbench file +(``tb_path``); an absolute path is used as given. Like the other operations, +it takes an optional trailing :ref:`session ` +parameter. .. _python_bridge:semantics: diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index 4e4c3f65d..20999648f 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -304,7 +304,8 @@ begin -- The difference shows for a file that has a state of its own. -- 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. + -- which puts counter and bump there. A relative file name is relative to the + -- directory of this testbench file, where bump.py is. exec_file("bump.py"); check_equal(integer'(call("bump")), 1); check_equal(integer'(call("bump")), 2); diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py index 4fecbc6ad..a923549ce 100644 --- a/tests/unit/test_python_bridge.py +++ b/tests/unit/test_python_bridge.py @@ -92,7 +92,7 @@ class _BridgeKey: def _write_run_script(path: Path) -> Path: """ - A stub run script; its directory is the base of relative Python file names. + A stub run script; its directory is put on sys.path by the runtime. """ path.write_text("# run script stub\n", encoding="utf-8") return path @@ -225,8 +225,8 @@ def _check_bridge_files_added(self, simulator): builtins.add("python") # The bridge must be handed the VUnit object's own project, output - # path, simulator class and run script path (the base of relative - # Python file names, shared with import_run_script). + # path, simulator class and run script path (whose directory the + # runtime puts on sys.path, like python does for a run script). setup_mock.assert_called_once_with( self.vu._project, self.vu._output_path, simulator, self.vu._run_script_path ) @@ -607,20 +607,20 @@ class TestConfigFile(unittest.TestCase): def test_config_keys_linux(self): with mock.patch("sys.platform", "linux"): - text = bridge_setup._config_text("/base/dir") # pylint: disable=protected-access + text = bridge_setup._config_text("/run/script/dir") # pylint: disable=protected-access keys = dict(line.split("=", 1) for line in text.splitlines()) - self.assertEqual(set(keys), {"executable", "prefix", "runtime", "base_dir"}) + self.assertEqual(set(keys), {"executable", "prefix", "runtime", "run_script_dir"}) self.assertEqual(keys["executable"], sys.executable) self.assertEqual(keys["prefix"], sys.prefix) self.assertEqual(keys["runtime"], str(bridge_setup.RUNTIME_SOURCE)) - self.assertEqual(keys["base_dir"], "/base/dir") + self.assertEqual(keys["run_script_dir"], "/run/script/dir") def test_config_keys_windows_include_python_dll(self): with ( mock.patch("sys.platform", "win32"), mock.patch("vunit.python_bridge.bridge.windows_python_dll", return_value=r"C:\python.dll"), ): - text = bridge_setup._config_text("/base/dir") # pylint: disable=protected-access + text = bridge_setup._config_text("/run/script/dir") # pylint: disable=protected-access keys = dict(line.split("=", 1) for line in text.splitlines()) self.assertEqual(keys["python_dll"], r"C:\python.dll") @@ -639,7 +639,7 @@ def test_write_if_changed_rewrites_changed_content(self): bridge_setup._write_if_changed(path, "world") # pylint: disable=protected-access self.assertEqual(path.read_text(encoding="utf-8"), "world") - def test_base_dir_is_the_directory_of_the_run_script(self): + def test_run_script_dir_is_the_directory_of_the_run_script(self): with create_tempdir() as tempdir: run_script = _write_run_script(tempdir / "run.py") fake_library_file = tempdir / "cache" / "libvunit_python_bridge.so" @@ -647,7 +647,7 @@ def test_base_dir_is_the_directory_of_the_run_script(self): bridge_setup.setup(_BridgeKey(), tempdir / "out", None, run_script) config = (fake_library_file.parent / bridge_setup.CONFIG_FILE_NAME).read_text(encoding="utf-8") keys = dict(line.split("=", 1) for line in config.splitlines()) - self.assertEqual(keys["base_dir"], str(tempdir.resolve())) + self.assertEqual(keys["run_script_dir"], str(tempdir.resolve())) def test_config_paths_with_spaces_and_unicode_written_as_utf8(self): with create_tempdir() as tempdir: @@ -663,7 +663,7 @@ def test_config_paths_with_spaces_and_unicode_written_as_utf8(self): def test_line_break_in_path_raises(self): with mock.patch("sys.executable", "/usr/bin/py\nthon"): with self.assertRaisesRegex(RuntimeError, "line breaks"): - bridge_setup._config_text("/base/dir") # pylint: disable=protected-access + bridge_setup._config_text("/run/script/dir") # pylint: disable=protected-access class TestWindowsDllSelection(unittest.TestCase): diff --git a/vunit/python_bridge/bridge.py b/vunit/python_bridge/bridge.py index 7fb516d3e..53afc56cc 100644 --- a/vunit/python_bridge/bridge.py +++ b/vunit/python_bridge/bridge.py @@ -62,8 +62,8 @@ def setup(project, output_path: str, simulator_class, run_script_path: Path) -> """ Prepare the Python bridge for a project. Called by add_python(). - :param run_script_path: The run script. Its directory is the base of - relative Python file names, like for import_run_script. + :param run_script_path: The run script. Its directory is put on sys.path + by the runtime, like when it is started by python. :returns: The bridge. Its vhdl_files are to be added to vunit_lib. """ simulator_name = None if simulator_class is None else simulator_class.name @@ -78,8 +78,8 @@ def setup(project, output_path: str, simulator_class, run_script_path: Path) -> is_fli = simulator_name in FLI_SIMULATORS root = Path(output_path) / "python_bridge" library_file = prepare_library(root, Path(simulator_class.find_prefix()) if is_fli else None) - base_dir = str(Path(run_script_path).resolve().parent) - _write_if_changed(library_file.parent / CONFIG_FILE_NAME, _config_text(base_dir)) + run_script_dir = str(Path(run_script_path).resolve().parent) + _write_if_changed(library_file.parent / CONFIG_FILE_NAME, _config_text(run_script_dir)) bridge_package = root / "vhdl" / "python_bridge_pkg.vhd" _write_if_changed( @@ -144,7 +144,7 @@ def get_bridge(project) -> Optional[PythonBridge]: return _BRIDGES.get(project) -def _config_text(base_dir: str) -> str: +def _config_text(run_script_dir: str) -> str: """ Content of the configuration file read by the bridge library at run time. """ @@ -152,7 +152,7 @@ def _config_text(base_dir: str) -> str: "executable": sys.executable, "prefix": sys.prefix, "runtime": str(RUNTIME_SOURCE), - "base_dir": base_dir, + "run_script_dir": run_script_dir, } if sys.platform == "win32": lines["python_dll"] = windows_python_dll() diff --git a/vunit/python_bridge/native/bridge.h b/vunit/python_bridge/native/bridge.h index a4dac7188..b743e3bfb 100644 --- a/vunit/python_bridge/native/bridge.h +++ b/vunit/python_bridge/native/bridge.h @@ -94,11 +94,11 @@ int vpy_has_error(void); /* config.c */ typedef struct { - char *executable; /* sys.executable of the Python running VUnit */ - char *prefix; /* its sys.prefix, verified by the runtime */ - char *runtime; /* path of runtime.py */ - char *base_dir; /* base of relative Python file names */ - char *python_dll; /* Windows only: path of the Python DLL */ + char *executable; /* sys.executable of the Python running VUnit */ + char *prefix; /* its sys.prefix, verified by the runtime */ + char *runtime; /* path of runtime.py */ + char *run_script_dir; /* directory of the run script, put on sys.path */ + char *python_dll; /* Windows only: path of the Python DLL */ } vpy_config_t; int vpy_read_config(vpy_config_t *config); diff --git a/vunit/python_bridge/native/config.c b/vunit/python_bridge/native/config.c index 3a27045e6..276956e07 100644 --- a/vunit/python_bridge/native/config.c +++ b/vunit/python_bridge/native/config.c @@ -142,8 +142,8 @@ static char **config_field(vpy_config_t *config, const char *key) { if (strcmp(key, "runtime") == 0) { return &config->runtime; } - if (strcmp(key, "base_dir") == 0) { - return &config->base_dir; + if (strcmp(key, "run_script_dir") == 0) { + return &config->run_script_dir; } if (strcmp(key, "python_dll") == 0) { return &config->python_dll; @@ -185,7 +185,8 @@ int vpy_read_config(vpy_config_t *config) { } fclose(file); - if (config->executable == NULL || config->prefix == NULL || config->runtime == NULL || config->base_dir == NULL) { + if (config->executable == NULL || config->prefix == NULL || config->runtime == NULL || + config->run_script_dir == NULL) { vpy_set_error("Incomplete Python bridge configuration file"); return VPY_ERROR; } diff --git a/vunit/python_bridge/native/interpreter.c b/vunit/python_bridge/native/interpreter.c index fcac0be2f..73d0aaec1 100644 --- a/vunit/python_bridge/native/interpreter.c +++ b/vunit/python_bridge/native/interpreter.c @@ -170,13 +170,14 @@ static int create_runtime(void) { "_module = importlib.util.module_from_spec(_spec)\n" "sys.modules[_spec.name] = _module\n" "_spec.loader.exec_module(_module)\n" - "runtime = _module.Runtime(base_dir=base_dir, prefix=prefix)\n"; + "runtime = _module.Runtime(run_script_dir=run_script_dir, prefix=prefix)\n"; PyObject *globals = PyDict_New(); PyObject *ret = NULL; int status = VPY_ERROR; if (globals == NULL || PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()) < 0 || - set_item(globals, "runtime_path", g_config.runtime) < 0 || set_item(globals, "base_dir", g_config.base_dir) < 0 || + set_item(globals, "runtime_path", g_config.runtime) < 0 || + set_item(globals, "run_script_dir", g_config.run_script_dir) < 0 || set_item(globals, "prefix", g_config.prefix) < 0) { vpy_set_error_from_python(); goto done; diff --git a/vunit/python_bridge/runtime.py b/vunit/python_bridge/runtime.py index 360b8fa7f..00ffa9371 100644 --- a/vunit/python_bridge/runtime.py +++ b/vunit/python_bridge/runtime.py @@ -182,8 +182,8 @@ class Runtime: # pylint: disable=too-many-instance-attributes shared by all exec and eval operations of a simulation. """ - def __init__(self, base_dir, prefix): - self._base_dir = Path(base_dir) + def __init__(self, run_script_dir, prefix): + self._run_script_dir = Path(run_script_dir) self._handle = BridgeHandle(self) # One namespace per session. The default session uses __main__. self._sessions = {DEFAULT_SESSION: __main__.__dict__} @@ -199,9 +199,12 @@ def __init__(self, base_dir, prefix): self._check_environment(prefix) - # Mimic "python run.py": the run script directory is sys.path[0]. - if str(self._base_dir) not in sys.path: - sys.path.insert(0, str(self._base_dir)) + # Mimic "python run.py": the run script directory is sys.path[0]. It is + # not used to resolve file names, which VHDL does, but import_run_script + # imports the run script as a module and it can then import its siblings + # like it does when the run script is started by python. + if str(self._run_script_dir) not in sys.path: + sys.path.insert(0, str(self._run_script_dir)) @staticmethod def _check_environment(prefix): @@ -325,16 +328,15 @@ def _execute_source(self, source): code = compile(source, file_name, "exec", dont_inherit=True) exec(code, self._namespace, self._namespace) # pylint: disable=exec-used - def _resolve_file(self, file_name): + @staticmethod + def _resolve_file(file_name): """ - Absolute path of a Python file, relative names are relative to the run script directory. + Absolute path of a Python file. VHDL resolves a relative file name + against the directory of its testbench before it gets here. """ if file_name == "": raise ValueError("Empty Python file name") - path = Path(file_name) - if not path.is_absolute(): - path = self._base_dir / path - return Path(os.path.normpath(path.absolute())) + return Path(os.path.normpath(Path(file_name).absolute())) def _execute_file(self, file_name): """ diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index 5851ebad7..cbbf456a0 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -258,8 +258,9 @@ package python_pkg is -- Execution of Python files ----------------------------------------------------------------------------- -- Execute a Python file. A relative file name is relative to the directory - -- of the run script. The file is executed with __file__ set and its own - -- directory on sys.path so that it can import its siblings. + -- of the testbench file, tb_path, and an absolute file name is used as it + -- is given. The file is executed with __file__ set and its own directory on + -- sys.path so that it can import its siblings. procedure exec_file(file_name : string; session : python_session_t := default_session); end package; @@ -880,10 +881,35 @@ package body python_pkg is ----------------------------------------------------------------------------- -- exec ----------------------------------------------------------------------------- + -- True when the file name is absolute: /a/b, \\server\share\a, C:\a or C:/a + function p_is_absolute(file_name : string) return boolean is + alias name : string(1 to file_name'length) is file_name; + begin + if name'length = 0 then + return false; + elsif name(1) = '/' or name(1) = '\' then + return true; + elsif name'length >= 2 then + -- A Windows drive + return name(2) = ':'; + end if; + return false; + end; + + -- The file name of exec_file, a relative one taken from the directory of the + -- testbench file like the file names of the other VUnit subprograms + impure function p_file_path(file_name : string) return string is + begin + if p_is_absolute(file_name) then + return file_name; + end if; + return join(tb_path(get_cfg(runner_state)), file_name); + end; + procedure exec_file(file_name : string; session : python_session_t := default_session) is variable ok : boolean; begin - ok := p_exec_file(file_name, session); + ok := p_exec_file(p_file_path(file_name), session); end; ----------------------------------------------------------------------------- diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd index bc1746183..5202b9968 100644 --- a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -160,40 +160,41 @@ begin check_equal(integer'(call("inc")), 3); elsif run("Test executing a file with a relative file name") then - exec_file("test/models/reference_model.py"); - check_equal(call_string("get_model_dir"), tb_path(runner_cfg) & "models"); + -- A relative file name is relative to the directory of the testbench + exec_file("models/reference_model.py"); + check_equal(call_string("get_model_dir"), join(tb_path(runner_cfg), "models")); elsif run("Test executing a file with an absolute file name") then - exec_file(tb_path(runner_cfg) & "models/reference_model.py"); - check_equal(call_string("get_model_dir"), tb_path(runner_cfg) & "models"); + exec_file(join(tb_path(runner_cfg), "models/reference_model.py")); + check_equal(call_string("get_model_dir"), join(tb_path(runner_cfg), "models")); elsif run("Test that __file__ is set during file execution and restored after") then - exec_file("test/models/reference_model.py"); - check_equal(call_string("get_file_during_exec"), tb_path(runner_cfg) & "models/reference_model.py"); + exec_file("models/reference_model.py"); + check_equal(call_string("get_file_during_exec"), join(tb_path(runner_cfg), "models/reference_model.py")); exec("def after_file_absent():" + " return '__file__' not in globals()"); check_true(call_boolean("after_file_absent")); elsif run("Test sibling import without PYTHONPATH and that sys.path is restored") then exec("GAIN = 3"); - exec_file("test/models/importer.py"); + exec_file("models/importer.py"); check_equal(integer'(call("scaled", arg(2))), 9); -- fir(2) = 3, 3 * GAIN(3) = 9 check_false(call_boolean("dir_in_syspath")); elsif run("Test that re-executing a file executes it again") then - exec_file("test/models/counter.py"); - exec_file("test/models/counter.py"); + exec_file("models/counter.py"); + exec_file("models/counter.py"); check_equal(integer'(call("get_call_count")), 2); elsif run("Test that exec_file with a missing file fails") then -- The error message shows the path in the native format of the OS exec("import os" + "def native_repr(path):" + " return repr(os.path.normpath(path))"); mock(python_logger, failure); - exec_file(tb_path(runner_cfg) & "models/does_not_exist.py"); + exec_file("models/does_not_exist.py"); check_only_log( python_logger, - "exec_file(""" & tb_path(runner_cfg) & "models/does_not_exist.py"") failed:" & LF & + "exec_file(""" & join(tb_path(runner_cfg), "models/does_not_exist.py") & """) failed:" & LF & "FileNotFoundError: [Errno 2] No such file or directory: " & - call_string("native_repr", arg(string'(tb_path(runner_cfg) & "models/does_not_exist.py"))), + call_string("native_repr", arg(string'(join(tb_path(runner_cfg), "models/does_not_exist.py")))), failure ); unmock(python_logger); @@ -864,9 +865,9 @@ begin unmock(python_logger); elsif run("Test executing a file in different sessions") then - exec_file("test/models/counter.py", golden); - exec_file("test/models/counter.py", golden); - exec_file("test/models/counter.py", fixed_point); + exec_file("models/counter.py", golden); + exec_file("models/counter.py", golden); + exec_file("models/counter.py", fixed_point); check_equal(integer'(call("get_call_count", session => golden)), 2); check_equal(integer'(call("get_call_count", session => fixed_point)), 1); diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 5ca09730f..57a2fe224 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -161,8 +161,9 @@ $call_declarations -- Execution of Python files ----------------------------------------------------------------------------- -- Execute a Python file. A relative file name is relative to the directory - -- of the run script. The file is executed with __file__ set and its own - -- directory on sys.path so that it can import its siblings. + -- of the testbench file, tb_path, and an absolute file name is used as it + -- is given. The file is executed with __file__ set and its own directory on + -- sys.path so that it can import its siblings. procedure exec_file(file_name : string; session : python_session_t := default_session); end package; @@ -679,10 +680,35 @@ $arg_functions ----------------------------------------------------------------------------- -- exec ----------------------------------------------------------------------------- + -- True when the file name is absolute: /a/b, \\server\share\a, C:\a or C:/a + function p_is_absolute(file_name : string) return boolean is + alias name : string(1 to file_name'length) is file_name; + begin + if name'length = 0 then + return false; + elsif name(1) = '/' or name(1) = '\' then + return true; + elsif name'length >= 2 then + -- A Windows drive + return name(2) = ':'; + end if; + return false; + end; + + -- The file name of exec_file, a relative one taken from the directory of the + -- testbench file like the file names of the other VUnit subprograms + impure function p_file_path(file_name : string) return string is + begin + if p_is_absolute(file_name) then + return file_name; + end if; + return join(tb_path(get_cfg(runner_state)), file_name); + end; + procedure exec_file(file_name : string; session : python_session_t := default_session) is variable ok : boolean; begin - ok := p_exec_file(file_name, session); + ok := p_exec_file(p_file_path(file_name), session); end; ----------------------------------------------------------------------------- From 6644db1245c399183ced2e36d4421cd59b88ab95 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:14:17 +0200 Subject: [PATCH 10/28] Keep all argument constructors in one place 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 --- vunit/vhdl/python/src/python_pkg.vhd | 229 +++++++++--------- .../vhdl/python/tools/generate_python_pkg.py | 34 ++- vunit/vhdl/python/tools/python_pkg.vhd.in | 176 +++++++------- 3 files changed, 204 insertions(+), 235 deletions(-) diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index cbbf456a0..df4ea7110 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -66,6 +66,36 @@ package python_pkg is function arg(value : boolean) return arg_t; function kwarg(kw : string; value : boolean) return arg_t; + -- real_vector and integer_vector_ptr_t values become Python lists, a + -- std_ulogic value becomes True or False, and an unsigned or signed value of + -- any width becomes a Python integer written as a hexadecimal literal, which + -- is therefore not limited to the range of a VHDL integer. + -- + -- An aggregate or a literal needs a qualified expression to select the + -- overload, for example arg(real_vector'(1.0, 2.0)). A std_ulogic_vector is + -- passed as a string, arg(to_string(slv)), or as a number, + -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of + -- their own since an overload would make a string literal argument, + -- arg("Hello"), ambiguous. + -- + -- H and L are read as 1 and 0. Any other metavalue is an error. + -- + -- An integer_array_t value is transferred to Python by the Python bridge, + -- which is only available for NVC, GHDL and Questa, and is referred to by + -- the expression, which means that it can be used in several calls. + function arg(value : real_vector) return arg_t; + function kwarg(kw : string; value : real_vector) return arg_t; + impure function arg(value : integer_vector_ptr_t) return arg_t; + impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t; + impure function arg(value : std_ulogic) return arg_t; + impure function kwarg(kw : string; value : std_ulogic) return arg_t; + impure function arg_unsigned(value : unsigned) return arg_t; + impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t; + impure function arg_signed(value : signed) return arg_t; + impure function kwarg_signed(kw : string; value : signed) return arg_t; + impure function arg(value : integer_array_t) return arg_t; + impure function kwarg(kw : string; value : integer_array_t) return arg_t; + impure function call_integer_w_arg( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session @@ -93,33 +123,6 @@ package python_pkg is alias call is call_real[ string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real]; - ----------------------------------------------------------------------------- - -- More argument values of call - ----------------------------------------------------------------------------- - -- real_vector and integer_vector_ptr_t values become Python lists, a - -- std_ulogic value becomes True or False, and an unsigned or signed value of - -- any width becomes a Python integer written as a hexadecimal literal, which - -- is therefore not limited to the range of a VHDL integer. - -- - -- An aggregate or a literal needs a qualified expression to select the - -- overload, for example arg(real_vector'(1.0, 2.0)). A std_ulogic_vector is - -- passed as a string, arg(to_string(slv)), or as a number, - -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of - -- their own since an overload would make a string literal argument, - -- arg("Hello"), ambiguous. - -- - -- H and L are read as 1 and 0. Any other metavalue is an error. - function arg(value : real_vector) return arg_t; - function kwarg(kw : string; value : real_vector) return arg_t; - impure function arg(value : integer_vector_ptr_t) return arg_t; - impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t; - impure function arg(value : std_ulogic) return arg_t; - impure function kwarg(kw : string; value : std_ulogic) return arg_t; - impure function arg_unsigned(value : unsigned) return arg_t; - impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t; - impure function arg_signed(value : signed) return arg_t; - impure function kwarg_signed(kw : string; value : signed) return arg_t; - ----------------------------------------------------------------------------- -- Keyword argument groups ----------------------------------------------------------------------------- @@ -144,14 +147,6 @@ package python_pkg is -- of them takes the session it is performed in as its last parameter, -- defaulting to the default session. - ----------------------------------------------------------------------------- - -- Argument values of call: integer arrays - ----------------------------------------------------------------------------- - -- An integer_array_t value is transferred to Python and referred to by the - -- expression, which means that it can be used in several calls. - impure function arg(value : integer_array_t) return arg_t; - impure function kwarg(kw : string; value : integer_array_t) return arg_t; - ----------------------------------------------------------------------------- -- Results of eval: boolean, std_ulogic, vectors and arrays ----------------------------------------------------------------------------- @@ -479,61 +474,6 @@ package body python_pkg is end if; end; - impure function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t - ) return string is - - function arg_to_str(value : arg_t) return string is - begin - if value.name = p_ignore_arg then - return ""; - elsif value.name = p_positional_arg then - return value.value; - else - return value.name & "=" & value.value; - end if; - end; - - constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; - begin - return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); - end; - - impure function call_integer_w_arg( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) return integer is - begin - return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - procedure call( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) is - begin - exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - impure function call_integer_vector( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) return integer_vector is - begin - return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - impure function call_real( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) return real is - begin - return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - ----------------------------------------------------------------------------- - -- More argument values of call - ----------------------------------------------------------------------------- -- The Python source text of an argument value. The operation is only used to -- report a value that cannot be converted, which gives an empty string. function p_arg_value(value : real_vector; operation : string) return string is @@ -644,6 +584,15 @@ package body python_pkg is return p_to_hex_literal(std_ulogic_vector(magnitude), true); end; + impure function p_arg_value(value : integer_array_t; operation : string) return string is + constant staged_id : integer := p_stage_array(value, operation); + begin + if staged_id < 1 then + return ""; + end if; + return "__vunit__.staged(" & integer'image(staged_id) & ")"; + end; + function arg(value : real_vector) return arg_t is constant text : string := p_arg_value(value, "arg"); begin @@ -734,6 +683,76 @@ package body python_pkg is return (kw, text); end; + impure function arg(value : integer_array_t) return arg_t is + constant text : string := p_arg_value(value, "arg"); + begin + if text = "" then + return null_arg; + end if; + return (p_positional_arg, text); + end; + + impure function kwarg(kw : string; value : integer_array_t) return arg_t is + constant text : string := p_arg_value(value, "kwarg"); + begin + if text = "" then + return null_arg; + end if; + return (kw, text); + end; + + impure function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t + ) return string is + + function arg_to_str(value : arg_t) return string is + begin + if value.name = p_ignore_arg then + return ""; + elsif value.name = p_positional_arg then + return value.value; + else + return value.name & "=" & value.value; + end if; + end; + + constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; + begin + return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); + end; + + impure function call_integer_w_arg( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_integer_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_real( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + ----------------------------------------------------------------------------- -- Keyword argument groups ----------------------------------------------------------------------------- @@ -848,36 +867,6 @@ package body python_pkg is return result.all; end; - ----------------------------------------------------------------------------- - -- arg and kwarg - ----------------------------------------------------------------------------- - impure function p_arg_value(value : integer_array_t; operation : string) return string is - constant staged_id : integer := p_stage_array(value, operation); - begin - if staged_id < 1 then - return ""; - end if; - return "__vunit__.staged(" & integer'image(staged_id) & ")"; - end; - - impure function arg(value : integer_array_t) return arg_t is - constant text : string := p_arg_value(value, "arg"); - begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); - end; - - impure function kwarg(kw : string; value : integer_array_t) return arg_t is - constant text : string := p_arg_value(value, "kwarg"); - begin - if text = "" then - return null_arg; - end if; - return (kw, text); - end; - ----------------------------------------------------------------------------- -- exec ----------------------------------------------------------------------------- diff --git a/vunit/vhdl/python/tools/generate_python_pkg.py b/vunit/vhdl/python/tools/generate_python_pkg.py index 770433e04..a146186ee 100644 --- a/vunit/vhdl/python/tools/generate_python_pkg.py +++ b/vunit/vhdl/python/tools/generate_python_pkg.py @@ -38,10 +38,10 @@ # vhdl: the VHDL type # impure: true when the conversion can fail, which makes the functions impure # suffix: appended to the arg and kwarg names, empty for an overload -# bridge: true when the value is transferred by the Python bridge, which makes -# it available for NVC, GHDL and Questa only. The other values are -# built from the Python source text of the value alone and work on any -# simulator. +# +# The integer_array_t value is transferred by the Python bridge, which makes it +# available for NVC, GHDL and Questa only. The other values are built from the +# Python source text of the value alone and work on any simulator. # # The unsigned and signed values are passed to typed names, arg_unsigned and # arg_signed, on purpose: plain overloads would make a string literal @@ -50,12 +50,12 @@ # std_ulogic_vector is deliberately not among the types at all. Such values # are passed as arg(to_string(slv)) and arg_unsigned(unsigned(slv)). ARG_VALUES = [ - dict(vhdl="real_vector", impure=False, suffix="", bridge=False), - dict(vhdl="integer_vector_ptr_t", impure=True, suffix="", bridge=False), - dict(vhdl="integer_array_t", impure=True, suffix="", bridge=True), - dict(vhdl="std_ulogic", impure=True, suffix="", bridge=False), - dict(vhdl="unsigned", impure=True, suffix="_unsigned", bridge=False), - dict(vhdl="signed", impure=True, suffix="_signed", bridge=False), + dict(vhdl="real_vector", impure=False, suffix=""), + dict(vhdl="integer_vector_ptr_t", impure=True, suffix=""), + dict(vhdl="std_ulogic", impure=True, suffix=""), + dict(vhdl="unsigned", impure=True, suffix="_unsigned"), + dict(vhdl="signed", impure=True, suffix="_signed"), + dict(vhdl="integer_array_t", impure=True, suffix=""), ] # Result types of eval and call. @@ -190,14 +190,12 @@ def call_name(result): # ------------------------------------------------------------------------- # arg and kwarg # ------------------------------------------------------------------------- -def arg_declarations(bridge): +def arg_declarations(): """ Declarations of the generated arg and kwarg subprograms. """ lines = [] for value in ARG_VALUES: - if value["bridge"] != bridge: - continue purity = "impure " if value["impure"] else "" suffix = value["suffix"] lines.append(f" {purity}function arg{suffix}(value : {value['vhdl']}) return arg_t;") @@ -205,15 +203,13 @@ def arg_declarations(bridge): return "\n".join(lines) -def arg_functions(bridge): +def arg_functions(): """ Bodies of the generated arg and kwarg subprograms. p_arg_value returns an empty string when the value cannot be converted, having reported it. """ parts = [] for value in ARG_VALUES: - if value["bridge"] != bridge: - continue purity = "impure " if value["impure"] else "" value_type = value["vhdl"] suffix = value["suffix"] @@ -373,12 +369,10 @@ def generate_package(): """ template = (TEMPLATE_PATH / "python_pkg.vhd.in").read_text(encoding="utf-8") return Template(template).substitute( - value_arg_declarations=arg_declarations(bridge=False), - value_arg_functions=arg_functions(bridge=False), - arg_declarations=arg_declarations(bridge=True), + arg_declarations=arg_declarations(), + arg_functions=arg_functions(), eval_declarations=eval_declarations(), call_declarations=call_declarations(), - arg_functions=arg_functions(bridge=True), eval_subprograms=eval_subprograms(), call_subprograms=call_subprograms(), ) diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 57a2fe224..dad06f568 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -66,6 +66,25 @@ package python_pkg is function arg(value : boolean) return arg_t; function kwarg(kw : string; value : boolean) return arg_t; + -- real_vector and integer_vector_ptr_t values become Python lists, a + -- std_ulogic value becomes True or False, and an unsigned or signed value of + -- any width becomes a Python integer written as a hexadecimal literal, which + -- is therefore not limited to the range of a VHDL integer. + -- + -- An aggregate or a literal needs a qualified expression to select the + -- overload, for example arg(real_vector'(1.0, 2.0)). A std_ulogic_vector is + -- passed as a string, arg(to_string(slv)), or as a number, + -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of + -- their own since an overload would make a string literal argument, + -- arg("Hello"), ambiguous. + -- + -- H and L are read as 1 and 0. Any other metavalue is an error. + -- + -- An integer_array_t value is transferred to Python by the Python bridge, + -- which is only available for NVC, GHDL and Questa, and is referred to by + -- the expression, which means that it can be used in several calls. +$arg_declarations + impure function call_integer_w_arg( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session @@ -93,24 +112,6 @@ package python_pkg is alias call is call_real[ string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real]; - ----------------------------------------------------------------------------- - -- More argument values of call - ----------------------------------------------------------------------------- - -- real_vector and integer_vector_ptr_t values become Python lists, a - -- std_ulogic value becomes True or False, and an unsigned or signed value of - -- any width becomes a Python integer written as a hexadecimal literal, which - -- is therefore not limited to the range of a VHDL integer. - -- - -- An aggregate or a literal needs a qualified expression to select the - -- overload, for example arg(real_vector'(1.0, 2.0)). A std_ulogic_vector is - -- passed as a string, arg(to_string(slv)), or as a number, - -- arg_unsigned(unsigned(slv)). The unsigned and signed values have names of - -- their own since an overload would make a string literal argument, - -- arg("Hello"), ambiguous. - -- - -- H and L are read as 1 and 0. Any other metavalue is an error. -$value_arg_declarations - ----------------------------------------------------------------------------- -- Keyword argument groups ----------------------------------------------------------------------------- @@ -135,13 +136,6 @@ $value_arg_declarations -- of them takes the session it is performed in as its last parameter, -- defaulting to the default session. - ----------------------------------------------------------------------------- - -- Argument values of call: integer arrays - ----------------------------------------------------------------------------- - -- An integer_array_t value is transferred to Python and referred to by the - -- expression, which means that it can be used in several calls. -$arg_declarations - ----------------------------------------------------------------------------- -- Results of eval: boolean, std_ulogic, vectors and arrays ----------------------------------------------------------------------------- @@ -382,61 +376,6 @@ package body python_pkg is end if; end; - impure function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t - ) return string is - - function arg_to_str(value : arg_t) return string is - begin - if value.name = p_ignore_arg then - return ""; - elsif value.name = p_positional_arg then - return value.value; - else - return value.name & "=" & value.value; - end if; - end; - - constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; - begin - return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); - end; - - impure function call_integer_w_arg( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) return integer is - begin - return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - procedure call( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) is - begin - exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - impure function call_integer_vector( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) return integer_vector is - begin - return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - impure function call_real( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; - session : python_session_t := default_session - ) return real is - begin - return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); - end; - - ----------------------------------------------------------------------------- - -- More argument values of call - ----------------------------------------------------------------------------- -- The Python source text of an argument value. The operation is only used to -- report a value that cannot be converted, which gives an empty string. function p_arg_value(value : real_vector; operation : string) return string is @@ -547,7 +486,68 @@ package body python_pkg is return p_to_hex_literal(std_ulogic_vector(magnitude), true); end; -$value_arg_functions + impure function p_arg_value(value : integer_array_t; operation : string) return string is + constant staged_id : integer := p_stage_array(value, operation); + begin + if staged_id < 1 then + return ""; + end if; + return "__vunit__.staged(" & integer'image(staged_id) & ")"; + end; + +$arg_functions + + impure function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t + ) return string is + + function arg_to_str(value : arg_t) return string is + begin + if value.name = p_ignore_arg then + return ""; + elsif value.name = p_positional_arg then + return value.value; + else + return value.name & "=" & value.value; + end if; + end; + + constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; + begin + return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); + end; + + impure function call_integer_w_arg( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + procedure call( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) is + begin + exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_integer_vector( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return integer_vector is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; + + impure function call_real( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; + session : python_session_t := default_session + ) return real is + begin + return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); + end; ----------------------------------------------------------------------------- -- Keyword argument groups @@ -663,20 +663,6 @@ $value_arg_functions return result.all; end; - ----------------------------------------------------------------------------- - -- arg and kwarg - ----------------------------------------------------------------------------- - impure function p_arg_value(value : integer_array_t; operation : string) return string is - constant staged_id : integer := p_stage_array(value, operation); - begin - if staged_id < 1 then - return ""; - end if; - return "__vunit__.staged(" & integer'image(staged_id) & ")"; - end; - -$arg_functions - ----------------------------------------------------------------------------- -- exec ----------------------------------------------------------------------------- From 59860e3f5191240522e054d0e54a1cfcdf1c9eb6 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:14:17 +0200 Subject: [PATCH 11/28] Drop the unused operation parameter of p_arg_value 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 --- vunit/vhdl/python/src/python_pkg.vhd | 17 +++++++++-------- .../vhdl/python/tools/generate_python_pkg.py | 19 +++++++++++-------- vunit/vhdl/python/tools/python_pkg.vhd.in | 9 +++++---- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index df4ea7110..8d5c8e193 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -474,14 +474,15 @@ package body python_pkg is end if; end; - -- The Python source text of an argument value. The operation is only used to - -- report a value that cannot be converted, which gives an empty string. - function p_arg_value(value : real_vector; operation : string) return string is + -- The Python source text of an argument value. The overloads whose + -- conversion can fail take the name of the operation, used to report a value + -- that cannot be converted, which gives an empty string. + function p_arg_value(value : real_vector) return string is begin return to_py_list_str(value); end; - impure function p_arg_value(value : integer_vector_ptr_t; operation : string) return string is + impure function p_arg_value(value : integer_vector_ptr_t) return string is begin return to_py_list_str(value); end; @@ -594,7 +595,7 @@ package body python_pkg is end; function arg(value : real_vector) return arg_t is - constant text : string := p_arg_value(value, "arg"); + constant text : string := p_arg_value(value); begin if text = "" then return null_arg; @@ -603,7 +604,7 @@ package body python_pkg is end; function kwarg(kw : string; value : real_vector) return arg_t is - constant text : string := p_arg_value(value, "kwarg"); + constant text : string := p_arg_value(value); begin if text = "" then return null_arg; @@ -612,7 +613,7 @@ package body python_pkg is end; impure function arg(value : integer_vector_ptr_t) return arg_t is - constant text : string := p_arg_value(value, "arg"); + constant text : string := p_arg_value(value); begin if text = "" then return null_arg; @@ -621,7 +622,7 @@ package body python_pkg is end; impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t is - constant text : string := p_arg_value(value, "kwarg"); + constant text : string := p_arg_value(value); begin if text = "" then return null_arg; diff --git a/vunit/vhdl/python/tools/generate_python_pkg.py b/vunit/vhdl/python/tools/generate_python_pkg.py index a146186ee..0204adc60 100644 --- a/vunit/vhdl/python/tools/generate_python_pkg.py +++ b/vunit/vhdl/python/tools/generate_python_pkg.py @@ -36,7 +36,9 @@ # template. # # vhdl: the VHDL type -# impure: true when the conversion can fail, which makes the functions impure +# impure: true when the functions must be impure +# fails: true when the conversion can fail, which makes p_arg_value take the +# name of the operation to report the value it cannot convert # suffix: appended to the arg and kwarg names, empty for an overload # # The integer_array_t value is transferred by the Python bridge, which makes it @@ -50,12 +52,12 @@ # std_ulogic_vector is deliberately not among the types at all. Such values # are passed as arg(to_string(slv)) and arg_unsigned(unsigned(slv)). ARG_VALUES = [ - dict(vhdl="real_vector", impure=False, suffix=""), - dict(vhdl="integer_vector_ptr_t", impure=True, suffix=""), - dict(vhdl="std_ulogic", impure=True, suffix=""), - dict(vhdl="unsigned", impure=True, suffix="_unsigned"), - dict(vhdl="signed", impure=True, suffix="_signed"), - dict(vhdl="integer_array_t", impure=True, suffix=""), + dict(vhdl="real_vector", impure=False, fails=False, suffix=""), + dict(vhdl="integer_vector_ptr_t", impure=True, fails=False, suffix=""), + dict(vhdl="std_ulogic", impure=True, fails=True, suffix=""), + dict(vhdl="unsigned", impure=True, fails=True, suffix="_unsigned"), + dict(vhdl="signed", impure=True, fails=True, suffix="_signed"), + dict(vhdl="integer_array_t", impure=True, fails=True, suffix=""), ] # Result types of eval and call. @@ -217,10 +219,11 @@ def arg_functions(): (f"arg{suffix}", f"value : {value_type}", "(p_positional_arg, text)"), (f"kwarg{suffix}", f"kw : string; value : {value_type}", "(kw, text)"), ]: + operation = f', "{name}"' if value["fails"] else "" parts.append( f"""\ {purity}function {name}({parameters}) return arg_t is - constant text : string := p_arg_value(value, "{name}"); + constant text : string := p_arg_value(value{operation}); begin if text = "" then return null_arg; diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index dad06f568..2a890664e 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -376,14 +376,15 @@ package body python_pkg is end if; end; - -- The Python source text of an argument value. The operation is only used to - -- report a value that cannot be converted, which gives an empty string. - function p_arg_value(value : real_vector; operation : string) return string is + -- The Python source text of an argument value. The overloads whose + -- conversion can fail take the name of the operation, used to report a value + -- that cannot be converted, which gives an empty string. + function p_arg_value(value : real_vector) return string is begin return to_py_list_str(value); end; - impure function p_arg_value(value : integer_vector_ptr_t; operation : string) return string is + impure function p_arg_value(value : integer_vector_ptr_t) return string is begin return to_py_list_str(value); end; From bbb18477b226b70d00abfbe878fc93c02b7071a3 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:14:17 +0200 Subject: [PATCH 12/28] Use is_x to find the metavalues of an argument value Review: ieee.std_logic_1164.is_x already has the semantics of the local p_has_metavalue. Co-Authored-By: Claude Fable 5.1 --- vunit/vhdl/python/src/python_pkg.vhd | 15 ++------------- vunit/vhdl/python/tools/python_pkg.vhd.in | 15 ++------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index 8d5c8e193..ecd57648f 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -501,17 +501,6 @@ package body python_pkg is return ""; end; - -- True when the vector has an element other than 0, 1, L and H - function p_has_metavalue(value : std_ulogic_vector) return boolean is - begin - for idx in value'range loop - if to_x01(value(idx)) = 'X' then - return true; - end if; - end loop; - return false; - end; - -- The Python hexadecimal literal of the magnitude given by the bits, for -- example 0x1F or -0x80. A null range gives 0x0. function p_to_hex_literal(bits : std_ulogic_vector; negative : boolean) return string is @@ -557,7 +546,7 @@ package body python_pkg is impure function p_arg_value(value : unsigned; operation : string) return string is begin - if p_has_metavalue(std_ulogic_vector(value)) then + if is_x(std_ulogic_vector(value)) then failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; @@ -568,7 +557,7 @@ package body python_pkg is variable folded : signed(value'length - 1 downto 0); variable magnitude : signed(value'length downto 0); begin - if p_has_metavalue(std_ulogic_vector(value)) then + if is_x(std_ulogic_vector(value)) then failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 2a890664e..16a089a52 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -403,17 +403,6 @@ package body python_pkg is return ""; end; - -- True when the vector has an element other than 0, 1, L and H - function p_has_metavalue(value : std_ulogic_vector) return boolean is - begin - for idx in value'range loop - if to_x01(value(idx)) = 'X' then - return true; - end if; - end loop; - return false; - end; - -- The Python hexadecimal literal of the magnitude given by the bits, for -- example 0x1F or -0x80. A null range gives 0x0. function p_to_hex_literal(bits : std_ulogic_vector; negative : boolean) return string is @@ -459,7 +448,7 @@ package body python_pkg is impure function p_arg_value(value : unsigned; operation : string) return string is begin - if p_has_metavalue(std_ulogic_vector(value)) then + if is_x(std_ulogic_vector(value)) then failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; @@ -470,7 +459,7 @@ package body python_pkg is variable folded : signed(value'length - 1 downto 0); variable magnitude : signed(value'length downto 0); begin - if p_has_metavalue(std_ulogic_vector(value)) then + if is_x(std_ulogic_vector(value)) then failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); return ""; end if; From 4d5c39909cae07239e06d9a2e054e16955e5fb56 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:14:17 +0200 Subject: [PATCH 13/28] Remove the string argument forms of to_call_str and call 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 --- docs/python_bridge/user_guide.rst | 2 +- examples/vhdl/embedded_python/tb_example.vhd | 2 +- vunit/vhdl/python/src/python_pkg.vhd | 40 +++++--------------- vunit/vhdl/python/tools/python_pkg.vhd.in | 40 +++++--------------- 4 files changed, 20 insertions(+), 64 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 0af7139a0..9b83d5fed 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -319,7 +319,7 @@ is useful to embed a call inside a larger ``exec``/``eval`` string: .. code-block:: vhdl - exec("gcd = " & to_call_str("py_gcd", to_string(35), to_string(77))); + exec("gcd = " & to_call_str("py_gcd", arg(35), arg(77))); Every unused trailing argument of ``call``/``to_call_str`` defaults to an ignored placeholder, so calls with fewer than 10 arguments need no padding. diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index 20999648f..8ca5fc5d4 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -361,7 +361,7 @@ begin check_equal(gcd, 7); -- Calls within an exec string can also be simplified - exec("gcd = " & to_call_str("gcd", to_string(35), to_string(77), to_string(119))); + exec("gcd = " & to_call_str("gcd", arg(35), arg(77), arg(119))); check_equal(eval("gcd"), 7); -- Calls to functions without a return value can be simplified with the call procedure diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index ecd57648f..53bb8b883 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -43,10 +43,6 @@ package python_pkg is ) return integer_vector_ptr_t; alias eval is eval_integer_vector_ptr[string, python_session_t return integer_vector_ptr_t]; - function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" - ) return string; - type arg_t is record name : string; value : string; @@ -96,6 +92,14 @@ package python_pkg is impure function arg(value : integer_array_t) return arg_t; impure function kwarg(kw : string; value : integer_array_t) return arg_t; + -- The Python expression calling identifier with the given arguments, for + -- example to embed a call in a larger exec or eval string: + -- + -- exec("gcd = " & to_call_str("gcd", arg(35), arg(77))); + impure function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg + ) return string; + impure function call_integer_w_arg( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session @@ -390,32 +394,6 @@ package body python_pkg is return result; end; - function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" - ) return string is - -- The non-empty arguments, separated by ", " - function append(args, arg : string) return string is - begin - if arg = "" then - return args; - elsif args = "" then - return arg; - else - return args & ", " & arg; - end if; - end; - begin - return identifier & "(" & append(append(append(append(append(append(append(append(append(append( - "", arg1), arg2), arg3), arg4), arg5), arg6), arg7), arg8), arg9), arg10) & ")"; - end; - - procedure call( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" - ) is - begin - exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)); - end; - function arg(value : integer) return arg_t is begin return (p_positional_arg, to_string(value)); @@ -692,7 +670,7 @@ package body python_pkg is end; impure function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg ) return string is function arg_to_str(value : arg_t) return string is diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 16a089a52..10af9e4b2 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -43,10 +43,6 @@ package python_pkg is ) return integer_vector_ptr_t; alias eval is eval_integer_vector_ptr[string, python_session_t return integer_vector_ptr_t]; - function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" - ) return string; - type arg_t is record name : string; value : string; @@ -85,6 +81,14 @@ package python_pkg is -- the expression, which means that it can be used in several calls. $arg_declarations + -- The Python expression calling identifier with the given arguments, for + -- example to embed a call in a larger exec or eval string: + -- + -- exec("gcd = " & to_call_str("gcd", arg(35), arg(77))); + impure function to_call_str( + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg + ) return string; + impure function call_integer_w_arg( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg; session : python_session_t := default_session @@ -292,32 +296,6 @@ package body python_pkg is return result; end; - function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" - ) return string is - -- The non-empty arguments, separated by ", " - function append(args, arg : string) return string is - begin - if arg = "" then - return args; - elsif args = "" then - return arg; - else - return args & ", " & arg; - end if; - end; - begin - return identifier & "(" & append(append(append(append(append(append(append(append(append(append( - "", arg1), arg2), arg3), arg4), arg5), arg6), arg7), arg8), arg9), arg10) & ")"; - end; - - procedure call( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : string := "" - ) is - begin - exec(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)); - end; - function arg(value : integer) return arg_t is begin return (p_positional_arg, to_string(value)); @@ -488,7 +466,7 @@ package body python_pkg is $arg_functions impure function to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t + identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg ) return string is function arg_to_str(value : arg_t) return string is From d33a06768884b390889877be5a7f21f1459c7771 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:19:14 +0200 Subject: [PATCH 14/28] Make a call with an argument that failed fail in Python 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 --- docs/python_bridge/user_guide.rst | 14 ++ vunit/python_bridge/runtime.py | 11 + vunit/vhdl/python/src/python_pkg.vhd | 211 +++++++----------- .../vhdl/python/test/tb_python_pkg_bridge.vhd | 63 +++++- .../vhdl/python/tools/generate_python_pkg.py | 23 +- vunit/vhdl/python/tools/python_pkg.vhd.in | 119 +++++----- 6 files changed, 233 insertions(+), 208 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 9b83d5fed..a0eb67516 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -295,6 +295,20 @@ belongs to every character array type and an overload would therefore make ``std_ulogic``, ``unsigned`` or ``signed`` value; any other metavalue is an error. +A value that cannot be converted is reported as a failure on +``python_logger``, and the argument becomes an expression raising the same +message in Python. The call it is used in therefore fails with that message as +well instead of being made without the argument, which is what would otherwise +happen when the logger is mocked and the simulation continues: + +.. code-block:: text + + FAILURE - vunit_lib:python - arg cannot convert 'X'; expected '0', '1', 'L' or 'H' + FAILURE - vunit_lib:python - eval("model(__vunit__.error("arg cannot convert 'X'; ..."))") failed: + Traceback (most recent call last): + ... + RuntimeError: arg cannot convert 'X'; expected '0', '1', 'L' or 'H' + An aggregate or a literal does not select an overload by itself and needs a qualified expression: ``arg(real_vector'(1.0, 2.0))``, ``arg(integer_vector'(1, 2, 3))``. There is no ``std_ulogic_vector`` value, diff --git a/vunit/python_bridge/runtime.py b/vunit/python_bridge/runtime.py index 00ffa9371..4e49975b2 100644 --- a/vunit/python_bridge/runtime.py +++ b/vunit/python_bridge/runtime.py @@ -172,6 +172,17 @@ def staged(self, staged_id): """ return self._runtime.staged(staged_id) + @staticmethod + def error(message): + """ + Raise the error VHDL reported for an argument it could not convert. + + Such an argument becomes __vunit__.error() in the Python + source text of the call, so that the call fails with the same message + instead of silently being made without the argument. + """ + raise RuntimeError(message) + def __repr__(self): return "" diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index 53bb8b883..1a6a71f41 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -452,9 +452,38 @@ package body python_pkg is end if; end; + -- The Python source text of a message, escaped for a double quoted Python + -- string. A quote, a backslash and a line break are the characters that + -- cannot be written as they are. + function p_quoted(message : string) return string is + variable result : line; + begin + swrite(result, """"); + for idx in message'range loop + case message(idx) is + when '"' | '\' => swrite(result, "\" & message(idx)); + when LF => swrite(result, "\n"); + when CR => swrite(result, "\r"); + when others => swrite(result, string'(1 => message(idx))); + end case; + end loop; + swrite(result, """"); + + return result.all; + end; + + -- Report a value that could not be converted and give it a Python source + -- text raising the same message, so that the call the argument is used in + -- fails the same way rather than being made without the argument. + impure function p_failed_value(message : string) return string is + begin + failure(python_logger, message); + return "__vunit__.error(" & p_quoted(message) & ")"; + end; + -- The Python source text of an argument value. The overloads whose -- conversion can fail take the name of the operation, used to report a value - -- that cannot be converted, which gives an empty string. + -- that cannot be converted. function p_arg_value(value : real_vector) return string is begin return to_py_list_str(value); @@ -472,11 +501,9 @@ package body python_pkg is elsif to_x01(value) = '0' then return "False"; end if; - failure( - python_logger, + return p_failed_value( operation & " cannot convert " & std_ulogic'image(value) & "; expected '0', '1', 'L' or 'H'" ); - return ""; end; -- The Python hexadecimal literal of the magnitude given by the bits, for @@ -525,8 +552,7 @@ package body python_pkg is impure function p_arg_value(value : unsigned; operation : string) return string is begin if is_x(std_ulogic_vector(value)) then - failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); - return ""; + return p_failed_value(p_metavalue_error(std_ulogic_vector(value), operation)); end if; return p_to_hex_literal(to_x01(std_ulogic_vector(value)), false); end; @@ -536,8 +562,7 @@ package body python_pkg is variable magnitude : signed(value'length downto 0); begin if is_x(std_ulogic_vector(value)) then - failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); - return ""; + return p_failed_value(p_metavalue_error(std_ulogic_vector(value), operation)); end if; if value'length = 0 then return "0x0"; @@ -556,137 +581,108 @@ package body python_pkg is constant staged_id : integer := p_stage_array(value, operation); begin if staged_id < 1 then - return ""; + -- p_stage_array reported the error of the transfer + return "__vunit__.error(" & p_quoted(operation & " failed") & ")"; end if; return "__vunit__.staged(" & integer'image(staged_id) & ")"; end; function arg(value : real_vector) return arg_t is - constant text : string := p_arg_value(value); begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); + return (p_positional_arg, p_arg_value(value)); end; function kwarg(kw : string; value : real_vector) return arg_t is - constant text : string := p_arg_value(value); begin - if text = "" then - return null_arg; - end if; - return (kw, text); + return (kw, p_arg_value(value)); end; impure function arg(value : integer_vector_ptr_t) return arg_t is - constant text : string := p_arg_value(value); begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); + return (p_positional_arg, p_arg_value(value)); end; impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t is - constant text : string := p_arg_value(value); begin - if text = "" then - return null_arg; - end if; - return (kw, text); + return (kw, p_arg_value(value)); end; impure function arg(value : std_ulogic) return arg_t is - constant text : string := p_arg_value(value, "arg"); begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); + return (p_positional_arg, p_arg_value(value, "arg")); end; impure function kwarg(kw : string; value : std_ulogic) return arg_t is - constant text : string := p_arg_value(value, "kwarg"); begin - if text = "" then - return null_arg; - end if; - return (kw, text); + return (kw, p_arg_value(value, "kwarg")); end; impure function arg_unsigned(value : unsigned) return arg_t is - constant text : string := p_arg_value(value, "arg_unsigned"); begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); + return (p_positional_arg, p_arg_value(value, "arg_unsigned")); end; impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t is - constant text : string := p_arg_value(value, "kwarg_unsigned"); begin - if text = "" then - return null_arg; - end if; - return (kw, text); + return (kw, p_arg_value(value, "kwarg_unsigned")); end; impure function arg_signed(value : signed) return arg_t is - constant text : string := p_arg_value(value, "arg_signed"); begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); + return (p_positional_arg, p_arg_value(value, "arg_signed")); end; impure function kwarg_signed(kw : string; value : signed) return arg_t is - constant text : string := p_arg_value(value, "kwarg_signed"); begin - if text = "" then - return null_arg; - end if; - return (kw, text); + return (kw, p_arg_value(value, "kwarg_signed")); end; impure function arg(value : integer_array_t) return arg_t is - constant text : string := p_arg_value(value, "arg"); begin - if text = "" then - return null_arg; - end if; - return (p_positional_arg, text); + return (p_positional_arg, p_arg_value(value, "arg")); end; impure function kwarg(kw : string; value : integer_array_t) return arg_t is - constant text : string := p_arg_value(value, "kwarg"); begin - if text = "" then - return null_arg; - end if; - return (kw, text); + return (kw, p_arg_value(value, "kwarg")); end; impure function to_call_str( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg ) return string is + variable result : line; + variable first : boolean := true; - function arg_to_str(value : arg_t) return string is + procedure append(value : arg_t) is begin if value.name = p_ignore_arg then - return ""; - elsif value.name = p_positional_arg then - return value.value; + return; + end if; + if not first then + swrite(result, ", "); + end if; + first := false; + if value.name = p_positional_arg then + swrite(result, value.value); else - return value.name & "=" & value.value; + swrite(result, value.name & "=" & value.value); end if; end; - - constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; begin - return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); + swrite(result, identifier & "("); + append(arg1); + append(arg2); + append(arg3); + append(arg4); + append(arg5); + append(arg6); + append(arg7); + append(arg8); + append(arg9); + append(arg10); + swrite(result, ")"); + + return result.all; end; impure function call_integer_w_arg( @@ -794,47 +790,6 @@ package body python_pkg is return result; end; - ----------------------------------------------------------------------------- - -- The Python expression calling identifier with the given arguments - ----------------------------------------------------------------------------- - function p_to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg - ) return string is - variable result : line; - variable first : boolean := true; - - procedure append(value : arg_t) is - begin - if value.name = p_ignore_arg then - return; - end if; - if not first then - swrite(result, ", "); - end if; - first := false; - if value.name = p_positional_arg then - swrite(result, value.value); - else - swrite(result, value.name & "=" & value.value); - end if; - end; - begin - swrite(result, identifier & "("); - append(arg1); - append(arg2); - append(arg3); - append(arg4); - append(arg5); - append(arg6); - append(arg7); - append(arg8); - append(arg9); - append(arg10); - swrite(result, ")"); - - return result.all; - end; - ----------------------------------------------------------------------------- -- exec ----------------------------------------------------------------------------- @@ -948,7 +903,7 @@ package body python_pkg is ) return real_vector is begin return eval_real_vector( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -958,7 +913,7 @@ package body python_pkg is ) return string is begin return eval_string( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -968,7 +923,7 @@ package body python_pkg is ) return integer_vector_ptr_t is begin return eval_integer_vector_ptr( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -978,7 +933,7 @@ package body python_pkg is ) return boolean is begin return eval_boolean( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -988,7 +943,7 @@ package body python_pkg is ) return std_ulogic is begin return eval_std_ulogic( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -998,7 +953,7 @@ package body python_pkg is ) return std_ulogic_vector is begin return eval_std_ulogic_vector( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -1008,7 +963,7 @@ package body python_pkg is ) return integer_array_t is begin return eval_integer_array( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session ); end; @@ -1019,7 +974,7 @@ package body python_pkg is ) is begin eval_std_ulogic_vector( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session ); end; @@ -1030,7 +985,7 @@ package body python_pkg is ) is begin eval_signed( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session ); end; @@ -1041,7 +996,7 @@ package body python_pkg is ) is begin eval_unsigned( - p_to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session ); end; end package body; diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd index 5202b9968..a73431a41 100644 --- a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -23,6 +23,19 @@ end entity; architecture tb of tb_python_pkg_bridge is constant std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; constant group_error : string := "Only keyword arguments can be combined with & into a keyword argument group"; + + -- The Python triple quote, used to keep a source text with quotes of its own + constant py_quotes : string := "'''"; + + -- The messages reported for argument values that cannot be converted and the + -- Python source text of the calls they are used in, where the quotes of the + -- message are escaped + constant std_ulogic_arg_error : string := "arg cannot convert 'X'; expected '0', '1', 'L' or 'H'"; + constant std_ulogic_arg_call : string := + "describe(__vunit__.error(""arg cannot convert 'X'; expected '0', '1', 'L' or 'H'""))"; + constant unsigned_arg_error : string := "kwarg_unsigned cannot convert ""1010X010""; the value has metavalues"; + constant unsigned_arg_call : string := + "describe(v=__vunit__.error(""kwarg_unsigned cannot convert \""1010X010\""; the value has metavalues""))"; begin main : process constant golden : python_session_t := "golden"; @@ -54,6 +67,10 @@ begin begin end; + procedure discard(value : arg_t) is + begin + end; + function ends_with(value, suffix : string) return boolean is alias normalized : string(1 to value'length) is value; begin @@ -469,7 +486,7 @@ begin python_logger, "eval(""no_such_function()"") failed:" & LF & call_string( - "expected_error", arg(string'("no_such_function()")), arg(string'("")), arg(true) + "expected_error", arg(string'("no_such_function()")), arg(string'("")), arg(true) ), failure ); @@ -660,15 +677,14 @@ begin check_equal(call_string("describe", arg('H'), arg('L')), "True, False"); elsif run("Test that a metavalue in an unsigned or signed argument fails") then - define_describe; mock(python_logger, failure); - check_equal(call_string("describe", arg_unsigned(unsigned'("1010X010"))), ""); + discard(arg_unsigned(unsigned'("1010X010"))); check_log( python_logger, "arg_unsigned cannot convert ""1010X010""; the value has metavalues", failure ); - check_equal(call_string("describe", arg_signed(signed'("10Z0"))), ""); + discard(arg_signed(signed'("10Z0"))); check_log(python_logger, "arg_signed cannot convert ""10Z0""; the value has metavalues", failure); - check_equal(call_string("describe", kwarg_unsigned("v", unsigned'("U"))), ""); + discard(kwarg_unsigned("v", unsigned'("U"))); check_only_log( python_logger, "kwarg_unsigned cannot convert ""U""; the value has metavalues", failure ); @@ -680,14 +696,43 @@ begin check_equal(call_string("describe", kwarg("v", '1'), kwarg("w", 'L')), "v=True, w=False"); elsif run("Test that a metavalue std_ulogic argument fails") then - define_describe; mock(python_logger, failure); - check_equal(call_string("describe", arg('X')), ""); + discard(arg('X')); check_log(python_logger, "arg cannot convert 'X'; expected '0', '1', 'L' or 'H'", failure); - check_equal(call_string("describe", kwarg("v", '-')), ""); + discard(kwarg("v", '-')); check_only_log(python_logger, "kwarg cannot convert '-'; expected '0', '1', 'L' or 'H'", failure); unmock(python_logger); + elsif run("Test that a call with an argument that could not be converted fails") then + -- The failed argument becomes an expression raising the message that + -- was reported, so that the call fails with it rather than being made + -- without the argument + define_describe; + define_error_helper; + mock(python_logger, failure); + + check_equal(call_string("describe", arg('X')), ""); + check_log(python_logger, std_ulogic_arg_error, failure); + exec("failing_source = r" & py_quotes & std_ulogic_arg_call & py_quotes); + check_log( + python_logger, + "eval(""" & std_ulogic_arg_call & """) failed:" & LF & + eval_string("expected_error(failing_source, '', True)"), + failure + ); + + -- A quote in the message is escaped to keep the Python source text valid + check_equal(call_string("describe", kwarg_unsigned("v", unsigned'("1010X010"))), ""); + check_log(python_logger, unsigned_arg_error, failure); + exec("failing_source = r" & py_quotes & unsigned_arg_call & py_quotes); + check_only_log( + python_logger, + "eval(""" & unsigned_arg_call & """) failed:" & LF & + eval_string("expected_error(failing_source, '', True)"), + failure + ); + unmock(python_logger); + elsif run("Test keyword forms of the typed argument values") then define_describe; check_equal(call_string("describe", kwarg_unsigned("v", unsigned'(x"FF"))), "v=255"); @@ -858,7 +903,7 @@ begin python_logger, "eval(""only_in_golden()"", session => ""fixed_point"") failed:" & LF & call_string( - "expected_error", arg(string'("only_in_golden()")), arg(string'("")), arg(true) + "expected_error", arg(string'("only_in_golden()")), arg(string'("")), arg(true) ), failure ); diff --git a/vunit/vhdl/python/tools/generate_python_pkg.py b/vunit/vhdl/python/tools/generate_python_pkg.py index 0204adc60..d1039742a 100644 --- a/vunit/vhdl/python/tools/generate_python_pkg.py +++ b/vunit/vhdl/python/tools/generate_python_pkg.py @@ -207,28 +207,25 @@ def arg_declarations(): def arg_functions(): """ - Bodies of the generated arg and kwarg subprograms. p_arg_value returns an - empty string when the value cannot be converted, having reported it. + Bodies of the generated arg and kwarg subprograms. p_arg_value reports a + value it cannot convert and gives it a Python source text raising the same + message, so that the call the argument is used in fails as well. """ parts = [] for value in ARG_VALUES: purity = "impure " if value["impure"] else "" value_type = value["vhdl"] suffix = value["suffix"] - for name, parameters, result in [ - (f"arg{suffix}", f"value : {value_type}", "(p_positional_arg, text)"), - (f"kwarg{suffix}", f"kw : string; value : {value_type}", "(kw, text)"), + for name, parameters, target in [ + (f"arg{suffix}", f"value : {value_type}", "p_positional_arg"), + (f"kwarg{suffix}", f"kw : string; value : {value_type}", "kw"), ]: - operation = f', "{name}"' if value["fails"] else "" + arg_value = f'p_arg_value(value, "{name}")' if value["fails"] else "p_arg_value(value)" parts.append( f"""\ {purity}function {name}({parameters}) return arg_t is - constant text : string := p_arg_value(value{operation}); begin - if text = "" then - return null_arg; - end if; - return {result}; + return ({target}, {arg_value}); end; """ ) @@ -343,7 +340,7 @@ def call_subprograms(): ) return {result['vhdl']} is begin return eval_{result['name']}( - p_to_call_str(identifier, {ARG_ACTUALS}), session + to_call_str(identifier, {ARG_ACTUALS}), session ); end; """ @@ -358,7 +355,7 @@ def call_subprograms(): ) is begin eval_{result['name']}( - p_to_call_str(identifier, {ARG_ACTUALS}), result, session + to_call_str(identifier, {ARG_ACTUALS}), result, session ); end; """ diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 10af9e4b2..8efd22b47 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -354,9 +354,38 @@ package body python_pkg is end if; end; + -- The Python source text of a message, escaped for a double quoted Python + -- string. A quote, a backslash and a line break are the characters that + -- cannot be written as they are. + function p_quoted(message : string) return string is + variable result : line; + begin + swrite(result, """"); + for idx in message'range loop + case message(idx) is + when '"' | '\' => swrite(result, "\" & message(idx)); + when LF => swrite(result, "\n"); + when CR => swrite(result, "\r"); + when others => swrite(result, string'(1 => message(idx))); + end case; + end loop; + swrite(result, """"); + + return result.all; + end; + + -- Report a value that could not be converted and give it a Python source + -- text raising the same message, so that the call the argument is used in + -- fails the same way rather than being made without the argument. + impure function p_failed_value(message : string) return string is + begin + failure(python_logger, message); + return "__vunit__.error(" & p_quoted(message) & ")"; + end; + -- The Python source text of an argument value. The overloads whose -- conversion can fail take the name of the operation, used to report a value - -- that cannot be converted, which gives an empty string. + -- that cannot be converted. function p_arg_value(value : real_vector) return string is begin return to_py_list_str(value); @@ -374,11 +403,9 @@ package body python_pkg is elsif to_x01(value) = '0' then return "False"; end if; - failure( - python_logger, + return p_failed_value( operation & " cannot convert " & std_ulogic'image(value) & "; expected '0', '1', 'L' or 'H'" ); - return ""; end; -- The Python hexadecimal literal of the magnitude given by the bits, for @@ -427,8 +454,7 @@ package body python_pkg is impure function p_arg_value(value : unsigned; operation : string) return string is begin if is_x(std_ulogic_vector(value)) then - failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); - return ""; + return p_failed_value(p_metavalue_error(std_ulogic_vector(value), operation)); end if; return p_to_hex_literal(to_x01(std_ulogic_vector(value)), false); end; @@ -438,8 +464,7 @@ package body python_pkg is variable magnitude : signed(value'length downto 0); begin if is_x(std_ulogic_vector(value)) then - failure(python_logger, p_metavalue_error(std_ulogic_vector(value), operation)); - return ""; + return p_failed_value(p_metavalue_error(std_ulogic_vector(value), operation)); end if; if value'length = 0 then return "0x0"; @@ -458,7 +483,8 @@ package body python_pkg is constant staged_id : integer := p_stage_array(value, operation); begin if staged_id < 1 then - return ""; + -- p_stage_array reported the error of the transfer + return "__vunit__.error(" & p_quoted(operation & " failed") & ")"; end if; return "__vunit__.staged(" & integer'image(staged_id) & ")"; end; @@ -468,21 +494,39 @@ $arg_functions impure function to_call_str( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg ) return string is + variable result : line; + variable first : boolean := true; - function arg_to_str(value : arg_t) return string is + procedure append(value : arg_t) is begin if value.name = p_ignore_arg then - return ""; - elsif value.name = p_positional_arg then - return value.value; + return; + end if; + if not first then + swrite(result, ", "); + end if; + first := false; + if value.name = p_positional_arg then + swrite(result, value.value); else - return value.name & "=" & value.value; + swrite(result, value.name & "=" & value.value); end if; end; - - constant args : string := "('" & arg_to_str(arg1) & "','" & arg_to_str(arg2) & "','" & arg_to_str(arg3) & "','" & arg_to_str(arg4) & "','" & arg_to_str(arg5) & "','" & arg_to_str(arg6) & "','" & arg_to_str(arg7) & "','" & arg_to_str(arg8) & "','" & arg_to_str(arg9) & "','" & arg_to_str(arg10) & "')"; begin - return eval_string("'" & identifier & "(' + ', '.join((arg for arg in " & args & " if arg)) + ')'"); + swrite(result, identifier & "("); + append(arg1); + append(arg2); + append(arg3); + append(arg4); + append(arg5); + append(arg6); + append(arg7); + append(arg8); + append(arg9); + append(arg10); + swrite(result, ")"); + + return result.all; end; impure function call_integer_w_arg( @@ -590,47 +634,6 @@ $arg_functions return result; end; - ----------------------------------------------------------------------------- - -- The Python expression calling identifier with the given arguments - ----------------------------------------------------------------------------- - function p_to_call_str( - identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg - ) return string is - variable result : line; - variable first : boolean := true; - - procedure append(value : arg_t) is - begin - if value.name = p_ignore_arg then - return; - end if; - if not first then - swrite(result, ", "); - end if; - first := false; - if value.name = p_positional_arg then - swrite(result, value.value); - else - swrite(result, value.name & "=" & value.value); - end if; - end; - begin - swrite(result, identifier & "("); - append(arg1); - append(arg2); - append(arg3); - append(arg4); - append(arg5); - append(arg6); - append(arg7); - append(arg8); - append(arg9); - append(arg10); - swrite(result, ")"); - - return result.all; - end; - ----------------------------------------------------------------------------- -- exec ----------------------------------------------------------------------------- From e4ffa8200bf69e4ced588dcb76aae37f4acd857b Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:22:14 +0200 Subject: [PATCH 15/28] Let positional arguments form groups too 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 --- docs/python_bridge/user_guide.rst | 30 +-- examples/vhdl/embedded_python/run.py | 4 +- examples/vhdl/embedded_python/tb_example.vhd | 6 +- vunit/vhdl/python/src/python_pkg.vhd | 182 ++++++++++++------ .../vhdl/python/test/tb_python_pkg_bridge.vhd | 32 ++- vunit/vhdl/python/tools/python_pkg.vhd.in | 182 ++++++++++++------ 6 files changed, 305 insertions(+), 131 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index a0eb67516..bbd666b62 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -338,23 +338,29 @@ is useful to embed a call inside a larger ``exec``/``eval`` string: Every unused trailing argument of ``call``/``to_call_str`` defaults to an ignored placeholder, so calls with fewer than 10 arguments need no padding. -Keyword argument groups -~~~~~~~~~~~~~~~~~~~~~~~~ +Argument groups +~~~~~~~~~~~~~~~~ -Keyword arguments combined with ``&`` become a single argument, which makes it -possible to pass more than 10 keyword arguments and to build the keyword -arguments of a call in steps. ``null_arg`` is the identity of the operation and -only keyword arguments, or groups of them, can be combined: +Arguments combined with ``&`` become a single argument, which makes it +possible to pass more than 10 arguments and to build the arguments of a call +in steps. ``null_arg`` is the identity of the operation: .. code-block:: vhdl -- Calls plot(data, **dict(title="Step response", grid=True)) call("plot", arg(data), kwarg("title", string'("Step response")) & kwarg("grid", true)); -The group is passed to Python as ``**dict(...)`` and therefore keeps Python's -own rules: it must come after the positional arguments of the call and a -keyword must not be repeated within it. It uses one of the 10 argument slots, -no matter how many keyword arguments it holds. + -- Calls scale(*(1, 2,), **dict(gain=3)) + call("scale", arg(1) & arg(2) & kwarg("gain", 3)); + +A group of positional arguments is passed to Python as ``*(1, 2,)``, a group +of keyword arguments as ``**dict(a=1, b=2)`` and a group of both as +``*(1,), **dict(a=1)``. It uses one of the 10 argument slots, no matter how +many arguments it holds, and keeps Python's own rules: a group with keyword +arguments must come after the positional arguments of the call and a keyword +must not be repeated within it. Appending a positional argument to a group +that already has keyword arguments is an error for the same reason, reported +as ``positional argument after keyword arguments``. import_run_script ------------------ @@ -559,8 +565,8 @@ See :vunit_example:`➚ examples/vhdl/embedded_python ` fo a complete example covering all three simulator families. Its ``tb_example.vhd`` has a test case for each part of the API: ``exec`` and -``eval`` with the types they convert, calls with positional, keyword and -keyword group arguments, a 20 register status dump, wide +``eval`` with the types they convert, calls with positional, keyword and group +arguments, a 20 register status dump, wide ``arg_unsigned``/``arg_signed`` and ``std_ulogic`` argument values, a 2-D ``integer_array_t`` image transposed by NumPy, the result types of ``eval`` and ``call``, Python files executed with ``exec_file`` or imported with diff --git a/examples/vhdl/embedded_python/run.py b/examples/vhdl/embedded_python/run.py index 6d5de29ff..4b7c79f36 100644 --- a/examples/vhdl/embedded_python/run.py +++ b/examples/vhdl/embedded_python/run.py @@ -11,8 +11,8 @@ Demonstrates calling Python from VHDL with ``add_python()``: executing Python code and calling Python functions, for example NumPy/Matplotlib reference models, from a testbench. The test cases cover ``exec`` and ``eval`` with the -types they convert, calls with positional, keyword and keyword group -arguments, wide ``unsigned``/``signed`` and ``std_ulogic`` argument values, an +types they convert, calls with positional, keyword and group arguments, wide +``unsigned``/``signed`` and ``std_ulogic`` argument values, an ``integer_array_t`` image shared with NumPy, Python files executed with ``exec_file`` or imported with ``import_module_from_file``, two models loaded into a session each, error reporting, and ``python_model``, a verification diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index 8ca5fc5d4..7ac5757d7 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -375,10 +375,10 @@ begin check_equal(eval("l[0]"), 2); check_equal(eval("l[1]"), 1); - elsif run("Test keyword argument groups") then - -- Keyword arguments combined with & become a single argument. That makes it + elsif run("Test argument groups") then + -- Arguments combined with & become a single argument. That makes it -- possible to pass more than the 10 arguments call takes, and to build the - -- keyword arguments of a call in steps, for example one group per register block. + -- arguments of a call in steps, for example one group per register block. import_device_model; -- A register dump: five samples passed positionally and 20 status registers diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index 1a6a71f41..4f3a1645f 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -49,6 +49,7 @@ package python_pkg is end record; constant p_positional_arg : string := "."; constant p_ignore_arg : string := "-"; + constant p_group_arg : string := "*"; constant null_arg : arg_t := (name => p_ignore_arg, value => ""); function arg(value : integer) return arg_t; @@ -128,18 +129,20 @@ package python_pkg is string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real]; ----------------------------------------------------------------------------- - -- Keyword argument groups + -- Argument groups ----------------------------------------------------------------------------- - -- Keyword arguments combined with & become a single argument, so that a call - -- can pass more than 10 keyword arguments and can build its keyword - -- arguments in steps: + -- Arguments combined with & become a single argument, so that a call can + -- pass more than the 10 arguments it takes and can build its arguments in + -- steps: -- -- call("f", arg(x), kwarg("a", 1) & kwarg("b", 2)); -- - -- calls f(x, **dict(a=1, b=2)). null_arg is the identity of the operation - -- and only keyword arguments, or groups of them, can be combined. Python - -- keeps its own rules: a repeated keyword and a group followed by a - -- positional argument are syntax errors. + -- calls f(x, **dict(a=1, b=2)). A group of positional arguments becomes + -- *(1, 2,) and a group with both kinds *(1,), **dict(a=1). null_arg is the + -- identity of the operation. Python keeps its own rules: a repeated keyword + -- within a group is a syntax error and so is a group followed by a + -- positional argument of the call. Appending a positional argument to a + -- group that already has keyword arguments is an error for the same reason. impure function "&"(l, r : arg_t) return arg_t; ----------------------------------------------------------------------------- @@ -647,6 +650,121 @@ package body python_pkg is return (kw, p_arg_value(value, "kwarg")); end; + ----------------------------------------------------------------------------- + -- Argument groups + ----------------------------------------------------------------------------- + -- A group is one argument whose Python source text is spliced into the call + -- like a positional argument: *(1, 2,) for positional arguments, + -- **dict(a=1, b=2) for keyword arguments and *(1,), **dict(a=1) for both. + -- Its name is the group marker followed by the length of the positional + -- part, which is what tells the two parts apart when more arguments are + -- appended to the group. + constant p_positional_group : string := "*("; + constant p_keyword_group : string := "**dict("; + + function p_is_group(value : arg_t) return boolean is + alias name : string(1 to value.name'length) is value.name; + begin + if name'length <= p_group_arg'length then + return false; + end if; + return name(1 to p_group_arg'length) = p_group_arg; + end; + + -- The length of the positional part of a group, 0 when it has none + function p_group_split(value : arg_t) return natural is + alias name : string(1 to value.name'length) is value.name; + begin + return natural'value(name(p_group_arg'length + 1 to name'length)); + end; + + -- The positional arguments of an operand as they are written within *(...). + -- Every argument is followed by a comma, which is what makes *(1,) a tuple. + function p_positional_items(value : arg_t) return string is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name = p_positional_arg then + return text & ","; + elsif p_is_group(value) then + if p_group_split(value) = 0 then + return ""; + end if; + -- Drop the *( prefix and the ) of the positional part + return text(p_positional_group'length + 1 to p_group_split(value) - 1); + end if; + return ""; + end; + + -- The keyword arguments of an operand as they are written within **dict(...) + function p_keyword_items(value : arg_t) return string is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name = p_ignore_arg or value.name = p_positional_arg then + return ""; + elsif not p_is_group(value) then + return value.name & "=" & value.value; + elsif p_group_split(value) = text'length then + return ""; + elsif p_group_split(value) = 0 then + -- Keyword arguments only: drop the **dict( prefix and the trailing ) + return text(p_keyword_group'length + 1 to text'length - 1); + end if; + -- Both kinds: the keyword part follows the positional part and ", " + return text(p_group_split(value) + 2 + p_keyword_group'length + 1 to text'length - 1); + end; + + -- The arguments of both operands, separated + function p_join(l, r, separator : string) return string is + begin + if l = "" then + return r; + elsif r = "" then + return l; + end if; + return l & separator & r; + end; + + -- *(...) or **dict(...) of the arguments, nothing when there are none + function p_group_text(prefix, items : string) return string is + begin + if items = "" then + return ""; + end if; + return prefix & items & ")"; + end; + + -- The group holding the given positional and keyword arguments + function p_group(positional_items, keyword_items : string) return arg_t is + constant positional : string := p_group_text(p_positional_group, positional_items); + constant keywords : string := p_group_text(p_keyword_group, keyword_items); + begin + if positional = "" then + return (p_group_arg & integer'image(0), keywords); + elsif keywords = "" then + return (p_group_arg & integer'image(positional'length), positional); + end if; + return (p_group_arg & integer'image(positional'length), positional & ", " & keywords); + end; + + impure function "&"(l, r : arg_t) return arg_t is + constant l_positional : string := p_positional_items(l); + constant l_keywords : string := p_keyword_items(l); + constant r_positional : string := p_positional_items(r); + constant r_keywords : string := p_keyword_items(r); + begin + if l.name = p_ignore_arg then + return r; + elsif r.name = p_ignore_arg then + return l; + elsif l_keywords /= "" and r_positional /= "" then + -- Python's own rule + failure(python_logger, "positional argument after keyword arguments"); + return null_arg; + end if; + + return p_group(p_join(l_positional, r_positional, " "), p_join(l_keywords, r_keywords, ", ")); + end; + impure function to_call_str( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg ) return string is @@ -662,7 +780,8 @@ package body python_pkg is swrite(result, ", "); end if; first := false; - if value.name = p_positional_arg then + if value.name = p_positional_arg or p_is_group(value) then + -- A group is spliced into the call like a positional argument swrite(result, value.value); else swrite(result, value.name & "=" & value.value); @@ -717,51 +836,6 @@ package body python_pkg is return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); end; - ----------------------------------------------------------------------------- - -- Keyword argument groups - ----------------------------------------------------------------------------- - -- A group of keyword arguments is a positional argument whose Python source - -- text is **dict(a=1, b=2), spliced into the call like any other positional - -- argument. The text of an arg(string) value is quoted and the text of the - -- other positional values is a number, a list or a name, so none of them can - -- be mistaken for a group. - constant p_group_prefix : string := "**dict("; - - function p_is_group(value : arg_t) return boolean is - alias text : string(1 to value.value'length) is value.value; - begin - if value.name /= p_positional_arg or text'length < p_group_prefix'length then - return false; - end if; - return text(1 to p_group_prefix'length) = p_group_prefix; - end; - - -- The keyword arguments of an operand as they are written within **dict(...) - function p_group_items(value : arg_t) return string is - alias text : string(1 to value.value'length) is value.value; - begin - if value.name = p_positional_arg then - -- A group: drop the **dict( prefix and the trailing ) - return text(p_group_prefix'length + 1 to text'length - 1); - end if; - return value.name & "=" & value.value; - end; - - impure function "&"(l, r : arg_t) return arg_t is - begin - if l.name = p_ignore_arg then - return r; - elsif r.name = p_ignore_arg then - return l; - elsif (l.name = p_positional_arg and not p_is_group(l)) or - (r.name = p_positional_arg and not p_is_group(r)) then - failure(python_logger, "Only keyword arguments can be combined with & into a keyword argument group"); - return null_arg; - end if; - - return (p_positional_arg, p_group_prefix & p_group_items(l) & ", " & p_group_items(r) & ")"); - end; - ----------------------------------------------------------------------------- -- Operations implemented by the Python bridge (NVC, GHDL and Questa) ----------------------------------------------------------------------------- diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd index a73431a41..5806b471b 100644 --- a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -22,7 +22,7 @@ end entity; architecture tb of tb_python_pkg_bridge is constant std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; - constant group_error : string := "Only keyword arguments can be combined with & into a keyword argument group"; + constant group_error : string := "positional argument after keyword arguments"; -- The Python triple quote, used to keep a source text with quotes of its own constant py_quotes : string := "'''"; @@ -546,7 +546,7 @@ begin bump_twice(arg(arr)); --------------------------------------------------------------------- - -- Keyword argument groups + -- Argument groups --------------------------------------------------------------------- elsif run("Test a keyword argument group") then define_describe; @@ -624,14 +624,34 @@ begin ); unmock(python_logger); - elsif run("Test that combining a positional argument with & fails") then + elsif run("Test a positional argument group") then + define_describe; + -- The group is a single argument holding the arguments of both operands + check_equal(call_string("describe", arg(1) & arg(string'("x"))), "1, 'x'"); + check_equal(call_string("describe", (arg(1) & arg(2)) & (arg(3) & arg(4))), "1, 2, 3, 4"); + check_equal(call_string("describe", arg(0), arg(1) & arg(2)), "0, 1, 2"); + check_equal(call_string("describe", null_arg & arg(1) & null_arg), "1"); + + elsif run("Test a mixed argument group") then + define_describe; + check_equal(call_string("describe", arg(1) & kwarg("a", 2)), "1, a=2"); + check_equal( + call_string("describe", (arg(1) & arg(2)) & (kwarg("a", 3) & kwarg("b", 4))), "1, 2, a=3, b=4" + ); + check_equal(call_string("describe", arg(1) & (kwarg("a", 2) & kwarg("b", 3))), "1, a=2, b=3"); + check_equal(call_string("describe", ((arg(1) & kwarg("a", 2)) & kwarg("b", 3)) & null_arg), "1, a=2, b=3"); + -- Also through to_call_str, which splices the group like a positional argument + exec("def add(x, y=0):" + " return x + y"); + check_equal(integer'(call("add", arg(1) & kwarg("y", 2))), 3); + + elsif run("Test that a positional argument after a keyword argument fails") then define_describe; mock(python_logger, failure); - check_equal(call_string("describe", arg(1) & kwarg("a", 2)), ""); + check_equal(call_string("describe", kwarg("a", 1) & arg(2)), ""); check_log(python_logger, group_error, failure); - check_equal(call_string("describe", kwarg("a", 2) & arg(1)), ""); + check_equal(call_string("describe", (arg(1) & kwarg("a", 2)) & arg(3)), ""); check_log(python_logger, group_error, failure); - check_equal(call_string("describe", (kwarg("a", 1) & kwarg("b", 2)) & arg(3)), ""); + check_equal(call_string("describe", (kwarg("a", 1) & kwarg("b", 2)) & (arg(3) & arg(4))), ""); check_only_log(python_logger, group_error, failure); unmock(python_logger); diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 8efd22b47..16dfef753 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -49,6 +49,7 @@ package python_pkg is end record; constant p_positional_arg : string := "."; constant p_ignore_arg : string := "-"; + constant p_group_arg : string := "*"; constant null_arg : arg_t := (name => p_ignore_arg, value => ""); function arg(value : integer) return arg_t; @@ -117,18 +118,20 @@ $arg_declarations string, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, arg_t, python_session_t return real]; ----------------------------------------------------------------------------- - -- Keyword argument groups + -- Argument groups ----------------------------------------------------------------------------- - -- Keyword arguments combined with & become a single argument, so that a call - -- can pass more than 10 keyword arguments and can build its keyword - -- arguments in steps: + -- Arguments combined with & become a single argument, so that a call can + -- pass more than the 10 arguments it takes and can build its arguments in + -- steps: -- -- call("f", arg(x), kwarg("a", 1) & kwarg("b", 2)); -- - -- calls f(x, **dict(a=1, b=2)). null_arg is the identity of the operation - -- and only keyword arguments, or groups of them, can be combined. Python - -- keeps its own rules: a repeated keyword and a group followed by a - -- positional argument are syntax errors. + -- calls f(x, **dict(a=1, b=2)). A group of positional arguments becomes + -- *(1, 2,) and a group with both kinds *(1,), **dict(a=1). null_arg is the + -- identity of the operation. Python keeps its own rules: a repeated keyword + -- within a group is a syntax error and so is a group followed by a + -- positional argument of the call. Appending a positional argument to a + -- group that already has keyword arguments is an error for the same reason. impure function "&"(l, r : arg_t) return arg_t; ----------------------------------------------------------------------------- @@ -491,6 +494,121 @@ package body python_pkg is $arg_functions + ----------------------------------------------------------------------------- + -- Argument groups + ----------------------------------------------------------------------------- + -- A group is one argument whose Python source text is spliced into the call + -- like a positional argument: *(1, 2,) for positional arguments, + -- **dict(a=1, b=2) for keyword arguments and *(1,), **dict(a=1) for both. + -- Its name is the group marker followed by the length of the positional + -- part, which is what tells the two parts apart when more arguments are + -- appended to the group. + constant p_positional_group : string := "*("; + constant p_keyword_group : string := "**dict("; + + function p_is_group(value : arg_t) return boolean is + alias name : string(1 to value.name'length) is value.name; + begin + if name'length <= p_group_arg'length then + return false; + end if; + return name(1 to p_group_arg'length) = p_group_arg; + end; + + -- The length of the positional part of a group, 0 when it has none + function p_group_split(value : arg_t) return natural is + alias name : string(1 to value.name'length) is value.name; + begin + return natural'value(name(p_group_arg'length + 1 to name'length)); + end; + + -- The positional arguments of an operand as they are written within *(...). + -- Every argument is followed by a comma, which is what makes *(1,) a tuple. + function p_positional_items(value : arg_t) return string is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name = p_positional_arg then + return text & ","; + elsif p_is_group(value) then + if p_group_split(value) = 0 then + return ""; + end if; + -- Drop the *( prefix and the ) of the positional part + return text(p_positional_group'length + 1 to p_group_split(value) - 1); + end if; + return ""; + end; + + -- The keyword arguments of an operand as they are written within **dict(...) + function p_keyword_items(value : arg_t) return string is + alias text : string(1 to value.value'length) is value.value; + begin + if value.name = p_ignore_arg or value.name = p_positional_arg then + return ""; + elsif not p_is_group(value) then + return value.name & "=" & value.value; + elsif p_group_split(value) = text'length then + return ""; + elsif p_group_split(value) = 0 then + -- Keyword arguments only: drop the **dict( prefix and the trailing ) + return text(p_keyword_group'length + 1 to text'length - 1); + end if; + -- Both kinds: the keyword part follows the positional part and ", " + return text(p_group_split(value) + 2 + p_keyword_group'length + 1 to text'length - 1); + end; + + -- The arguments of both operands, separated + function p_join(l, r, separator : string) return string is + begin + if l = "" then + return r; + elsif r = "" then + return l; + end if; + return l & separator & r; + end; + + -- *(...) or **dict(...) of the arguments, nothing when there are none + function p_group_text(prefix, items : string) return string is + begin + if items = "" then + return ""; + end if; + return prefix & items & ")"; + end; + + -- The group holding the given positional and keyword arguments + function p_group(positional_items, keyword_items : string) return arg_t is + constant positional : string := p_group_text(p_positional_group, positional_items); + constant keywords : string := p_group_text(p_keyword_group, keyword_items); + begin + if positional = "" then + return (p_group_arg & integer'image(0), keywords); + elsif keywords = "" then + return (p_group_arg & integer'image(positional'length), positional); + end if; + return (p_group_arg & integer'image(positional'length), positional & ", " & keywords); + end; + + impure function "&"(l, r : arg_t) return arg_t is + constant l_positional : string := p_positional_items(l); + constant l_keywords : string := p_keyword_items(l); + constant r_positional : string := p_positional_items(r); + constant r_keywords : string := p_keyword_items(r); + begin + if l.name = p_ignore_arg then + return r; + elsif r.name = p_ignore_arg then + return l; + elsif l_keywords /= "" and r_positional /= "" then + -- Python's own rule + failure(python_logger, "positional argument after keyword arguments"); + return null_arg; + end if; + + return p_group(p_join(l_positional, r_positional, " "), p_join(l_keywords, r_keywords, ", ")); + end; + impure function to_call_str( identifier : string; arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10 : arg_t := null_arg ) return string is @@ -506,7 +624,8 @@ $arg_functions swrite(result, ", "); end if; first := false; - if value.name = p_positional_arg then + if value.name = p_positional_arg or p_is_group(value) then + -- A group is spliced into the call like a positional argument swrite(result, value.value); else swrite(result, value.name & "=" & value.value); @@ -561,51 +680,6 @@ $arg_functions return eval(to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session); end; - ----------------------------------------------------------------------------- - -- Keyword argument groups - ----------------------------------------------------------------------------- - -- A group of keyword arguments is a positional argument whose Python source - -- text is **dict(a=1, b=2), spliced into the call like any other positional - -- argument. The text of an arg(string) value is quoted and the text of the - -- other positional values is a number, a list or a name, so none of them can - -- be mistaken for a group. - constant p_group_prefix : string := "**dict("; - - function p_is_group(value : arg_t) return boolean is - alias text : string(1 to value.value'length) is value.value; - begin - if value.name /= p_positional_arg or text'length < p_group_prefix'length then - return false; - end if; - return text(1 to p_group_prefix'length) = p_group_prefix; - end; - - -- The keyword arguments of an operand as they are written within **dict(...) - function p_group_items(value : arg_t) return string is - alias text : string(1 to value.value'length) is value.value; - begin - if value.name = p_positional_arg then - -- A group: drop the **dict( prefix and the trailing ) - return text(p_group_prefix'length + 1 to text'length - 1); - end if; - return value.name & "=" & value.value; - end; - - impure function "&"(l, r : arg_t) return arg_t is - begin - if l.name = p_ignore_arg then - return r; - elsif r.name = p_ignore_arg then - return l; - elsif (l.name = p_positional_arg and not p_is_group(l)) or - (r.name = p_positional_arg and not p_is_group(r)) then - failure(python_logger, "Only keyword arguments can be combined with & into a keyword argument group"); - return null_arg; - end if; - - return (p_positional_arg, p_group_prefix & p_group_items(l) & ", " & p_group_items(r) & ")"); - end; - ----------------------------------------------------------------------------- -- Operations implemented by the Python bridge (NVC, GHDL and Questa) ----------------------------------------------------------------------------- From 9e0cdfdbe45f79a1e2e59328715640d12bc9b777 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:22:52 +0200 Subject: [PATCH 16/28] Say why null_arg is the identity of an argument group Review: The comment above & and the user guide should explain what the identity is good for. Co-Authored-By: Claude Fable 5.1 --- docs/python_bridge/user_guide.rst | 24 ++++++++++++++++++++++- vunit/vhdl/python/src/python_pkg.vhd | 16 ++++++++++----- vunit/vhdl/python/tools/python_pkg.vhd.in | 16 ++++++++++----- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index bbd666b62..3986cf906 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -343,7 +343,7 @@ Argument groups Arguments combined with ``&`` become a single argument, which makes it possible to pass more than 10 arguments and to build the arguments of a call -in steps. ``null_arg`` is the identity of the operation: +in steps: .. code-block:: vhdl @@ -362,6 +362,28 @@ must not be repeated within it. Appending a positional argument to a group that already has keyword arguments is an error for the same reason, reported as ``positional argument after keyword arguments``. +``null_arg`` is the identity of the operation. That is what makes a group +possible to build one argument at a time, ending with ``null_arg``, and what +lets a helper that contributes an optional argument return ``null_arg`` when +it has none. An empty group is a call with no arguments rather than an error. +Since ``arg_t`` holds unconstrained strings, a group is grown by a function +rather than by assigning to a variable in a loop: + +.. code-block:: vhdl + + -- The registers from idx and up, an empty group when there are none + impure function registers(idx : natural) return arg_t is + begin + if idx = num_registers then + return null_arg; + end if; + return kwarg(name(idx), value(idx)) & registers(idx + 1); + end; + + ... + + call("model.configure", registers(0)); + import_run_script ------------------ diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index 4f3a1645f..1bac31a8b 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -138,11 +138,17 @@ package python_pkg is -- call("f", arg(x), kwarg("a", 1) & kwarg("b", 2)); -- -- calls f(x, **dict(a=1, b=2)). A group of positional arguments becomes - -- *(1, 2,) and a group with both kinds *(1,), **dict(a=1). null_arg is the - -- identity of the operation. Python keeps its own rules: a repeated keyword - -- within a group is a syntax error and so is a group followed by a - -- positional argument of the call. Appending a positional argument to a - -- group that already has keyword arguments is an error for the same reason. + -- *(1, 2,) and a group with both kinds *(1,), **dict(a=1). Python keeps its + -- own rules: a repeated keyword within a group is a syntax error and so is a + -- group followed by a positional argument of the call. Appending a + -- positional argument to a group that already has keyword arguments is an + -- error for the same reason. + -- + -- null_arg is the identity of the operation, which is what makes a group + -- possible to build one argument at a time, ending with null_arg, and what + -- lets a helper that contributes an optional argument return null_arg when + -- it has none. An empty group is a valid call with no arguments rather than + -- an error. impure function "&"(l, r : arg_t) return arg_t; ----------------------------------------------------------------------------- diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 16dfef753..3c241b0e9 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -127,11 +127,17 @@ $arg_declarations -- call("f", arg(x), kwarg("a", 1) & kwarg("b", 2)); -- -- calls f(x, **dict(a=1, b=2)). A group of positional arguments becomes - -- *(1, 2,) and a group with both kinds *(1,), **dict(a=1). null_arg is the - -- identity of the operation. Python keeps its own rules: a repeated keyword - -- within a group is a syntax error and so is a group followed by a - -- positional argument of the call. Appending a positional argument to a - -- group that already has keyword arguments is an error for the same reason. + -- *(1, 2,) and a group with both kinds *(1,), **dict(a=1). Python keeps its + -- own rules: a repeated keyword within a group is a syntax error and so is a + -- group followed by a positional argument of the call. Appending a + -- positional argument to a group that already has keyword arguments is an + -- error for the same reason. + -- + -- null_arg is the identity of the operation, which is what makes a group + -- possible to build one argument at a time, ending with null_arg, and what + -- lets a helper that contributes an optional argument return null_arg when + -- it has none. An empty group is a valid call with no arguments rather than + -- an error. impure function "&"(l, r : arg_t) return arg_t; ----------------------------------------------------------------------------- From 91ff756d6c6b25d4067795a9021b4470fd86c58c Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 10:26:37 +0200 Subject: [PATCH 17/28] Make a Python session a VUnit object 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 --- docs/python_bridge/user_guide.rst | 25 +++- examples/vhdl/embedded_python/tb_example.vhd | 4 +- .../vhdl/python/src/python_ffi_pkg_bridge.vhd | 124 ++++++++++++++---- vunit/vhdl/python/src/python_pkg_vhpi.vhd | 77 ++++++++--- .../vhdl/python/test/tb_python_pkg_bridge.vhd | 30 +++-- 5 files changed, 197 insertions(+), 63 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 3986cf906..83544f631 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -100,19 +100,20 @@ Every operation that runs Python code or evaluates a Python expression -- ``import_run_script`` and ``import_module_from_file`` -- takes an optional trailing parameter, ``session : python_session_t := default_session``, that selects the namespace -the operation runs in. A session is created the first time it is used; the +the operation runs in. A session is made from its name with ``new_session`` +and its Python namespace is created the first time it is used; the ``default_session`` constant used when the parameter is omitted runs in ``__main__``. .. code-block:: vhdl - constant golden : python_session_t := "golden"; - constant fixed_point : python_session_t := "fixed_point"; + constant golden : python_session_t := new_session("golden"); + constant fixed_point : python_session_t := new_session("fixed_point"); ... exec("x = 1", session => golden); - exec_file("models/fixed_point.py", session => fixed_point); + exec_file("fixed_point.py", session => fixed_point); expected := eval("model(x)", session => golden); got := call("model", arg(x), session => fixed_point); @@ -121,6 +122,22 @@ Since ``session`` comes after up to 10 positional arguments in ``call``, it is normally given by name, as above. Both sessions in the example can define ``model`` without interfering with each other. +The name of a session is a VUnit :ref:`identity ` under +``vunit_lib:python``, the identity of ``python_logger``, and ``name(session)`` +returns it. Two sessions made from the same name are the same session, so a +session does not have to be passed around to be used in several places. + +Errors of an operation performed in a session other than the default one are +reported on the logger of that session, ``get_logger(, +python_logger)``, which is a child of ``python_logger``. Mocking a logger does +not capture what its children log, which is why the default session reports on +``python_logger`` itself and why a test that mocks the errors of a session +mocks that session's logger: + +.. code-block:: vhdl + + mock(get_logger("golden", python_logger), failure); + On Riviera-PRO/Active-HDL (VHPI), only the default session is supported: passing any other session fails with a clear error. diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index 7ac5757d7..f686ef796 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -52,8 +52,8 @@ begin -- A session is a Python namespace of its own, which is what makes it -- possible to have two models that both define model and config - constant golden : python_session_t := "golden"; - constant fixed_point : python_session_t := "fixed_point"; + constant golden : python_session_t := new_session("golden"); + constant fixed_point : python_session_t := new_session("fixed_point"); variable cycles : unsigned(63 downto 0) := x"123456789ABCDEF0"; variable value : unsigned(31 downto 0) := x"DEADBEEF"; diff --git a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd index fce968a3e..a38738453 100644 --- a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd +++ b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd @@ -21,22 +21,43 @@ -- variants of this package declare the same primitives and implement them by -- reporting a failure. +use work.id_pkg.all; use work.integer_array_pkg.all; +use work.integer_vector_ptr_pkg.all; use work.logger_pkg.all; use work.python_bridge_pkg.all; package python_ffi_pkg is - -- Logger used to report Python errors, for example exceptions with their traceback - constant python_logger : logger_t := get_logger("vunit_lib:python"); - - -- A session is a named Python namespace. Sessions are created on first use - -- and are isolated from each other, for example + -- The identity of the Python interface. It is the parent of the session + -- identities and of python_logger. + constant p_python_id : id_t := get_id("vunit_lib:python"); + + -- Logger used to report Python errors, for example exceptions with their + -- traceback. An operation performed in a session other than the default one + -- reports on the logger of that session, get_logger(, + -- python_logger), which is a child of python_logger. + constant python_logger : logger_t := get_logger(p_python_id); + + -- A session is a named Python namespace. Sessions are isolated from each + -- other and are created from their name, which is an identity under the + -- identity of the Python interface: -- - -- constant golden_model : python_session_t := "golden_model"; + -- constant golden_model : python_session_t := new_session("golden_model"); -- - -- The default session is the __main__ namespace. - type python_session_t is array (positive range <>) of character; - constant default_session : python_session_t := "default"; + -- The Python namespace is created on first use. The default session is the + -- __main__ namespace. + type python_session_t is record + p_data : integer_vector_ptr_t; + end record; + + impure function new_session(name : string) return python_session_t; + impure function name(session : python_session_t) return string; + + -- The session the operations are performed in when no other one is given. + -- It is created the way new_session does, which cannot be called here since + -- its body is not elaborated until the package body is. + constant default_session : python_session_t := + (p_data => new_integer_vector_ptr(1, value => to_integer(get_id("default", parent => p_python_id)))); -- Start the embedded Python interpreter eagerly. Optional and idempotent: -- without it the interpreter starts on first use. @@ -86,18 +107,23 @@ package python_ffi_pkg is constant p_kind_integer_vector : integer := 9; constant p_kind_real_vector : integer := 10; + -- The logger the operations of a session report on + impure function p_logger(session : python_session_t := default_session) return logger_t; + -- Names of the operations, used in the error messages - function p_exec_operation(session : python_session_t := default_session) return string; - function p_exec_file_operation( + impure function p_exec_operation(session : python_session_t := default_session) return string; + impure function p_exec_file_operation( file_name : string; session : python_session_t := default_session ) return string; - function p_eval_operation(expr : string; session : python_session_t := default_session) return string; + impure function p_eval_operation(expr : string; session : python_session_t := default_session) return string; -- The error of the last failed bridge operation, typically a Python traceback impure function p_error_text return string; -- Report a failed operation (status /= 0) with its error. True if it succeeded. - impure function p_succeeded(status : integer; operation : string) return boolean; + impure function p_succeeded( + status : integer; operation : string; logger : logger_t := python_logger + ) return boolean; -- Transfer a string to the bridge buffer. Returns the bridge status. impure function p_send(text : string) return integer; @@ -143,33 +169,68 @@ package python_ffi_pkg is end package; package body python_ffi_pkg is + ----------------------------------------------------------------------------- + -- Sessions + ----------------------------------------------------------------------------- + impure function new_session(name : string) return python_session_t is + begin + return (p_data => new_integer_vector_ptr(1, value => to_integer(get_id(name, parent => p_python_id)))); + end; + + -- The identity of a session, which is what two sessions are compared by + impure function p_id(session : python_session_t) return id_t is + begin + return to_id(get(session.p_data, 0)); + end; + + impure function name(session : python_session_t) return string is + begin + return name(p_id(session)); + end; + + impure function p_is_default(session : python_session_t) return boolean is + begin + return p_id(session) = p_id(default_session); + end; + + -- Mocking a logger does not capture the logs of its children, so the + -- operations of the default session report on python_logger itself rather + -- than on a logger of their own. + impure function p_logger(session : python_session_t := default_session) return logger_t is + begin + if p_is_default(session) then + return python_logger; + end if; + return get_logger(p_id(session)); + end; + ----------------------------------------------------------------------------- -- Operation names ----------------------------------------------------------------------------- - function p_exec_operation(session : python_session_t := default_session) return string is + impure function p_exec_operation(session : python_session_t := default_session) return string is begin - if session = default_session then + if p_is_default(session) then return "exec"; end if; - return "exec(session => """ & string(session) & """)"; + return "exec(session => """ & name(session) & """)"; end; - function p_exec_file_operation( + impure function p_exec_file_operation( file_name : string; session : python_session_t := default_session ) return string is begin - if session = default_session then + if p_is_default(session) then return "exec_file(""" & file_name & """)"; end if; - return "exec_file(""" & file_name & """, session => """ & string(session) & """)"; + return "exec_file(""" & file_name & """, session => """ & name(session) & """)"; end; - function p_eval_operation(expr : string; session : python_session_t := default_session) return string is + impure function p_eval_operation(expr : string; session : python_session_t := default_session) return string is begin - if session = default_session then + if p_is_default(session) then return "eval(""" & expr & """)"; end if; - return "eval(""" & expr & """, session => """ & string(session) & """)"; + return "eval(""" & expr & """, session => """ & name(session) & """)"; end; ----------------------------------------------------------------------------- @@ -190,12 +251,14 @@ package body python_ffi_pkg is return result; end; - impure function p_succeeded(status : integer; operation : string) return boolean is + impure function p_succeeded( + status : integer; operation : string; logger : logger_t := python_logger + ) return boolean is begin if status = 0 then return true; end if; - failure(python_logger, operation & " failed:" & LF & p_error_text); + failure(logger, operation & " failed:" & LF & p_error_text); return false; end; @@ -216,8 +279,9 @@ package body python_ffi_pkg is end; impure function p_begin(session : python_session_t; operation : string) return boolean is + constant logger : logger_t := p_logger(session); begin - return p_succeeded(p_send(string(session)), operation) and p_succeeded(vpy_begin, operation); + return p_succeeded(p_send(name(session)), operation, logger) and p_succeeded(vpy_begin, operation, logger); end; impure function p_exec( @@ -226,10 +290,11 @@ package body python_ffi_pkg is operation : string; session : python_session_t := default_session ) return boolean is + constant logger : logger_t := p_logger(session); begin return p_begin(session, operation) - and p_succeeded(p_send(text), operation) - and p_succeeded(vpy_execute(is_file), operation); + and p_succeeded(p_send(text), operation, logger) + and p_succeeded(vpy_execute(is_file), operation, logger); end; impure function p_exec_file( @@ -246,10 +311,11 @@ package body python_ffi_pkg is operation : string; session : python_session_t := default_session ) return boolean is + constant logger : logger_t := p_logger(session); begin return p_begin(session, operation) - and p_succeeded(p_send(expr), operation) - and p_succeeded(vpy_eval(kind, width), operation); + and p_succeeded(p_send(expr), operation, logger) + and p_succeeded(vpy_eval(kind, width), operation, logger); end; ----------------------------------------------------------------------------- diff --git a/vunit/vhdl/python/src/python_pkg_vhpi.vhd b/vunit/vhdl/python/src/python_pkg_vhpi.vhd index 4f15255ae..1a04b8f59 100644 --- a/vunit/vhdl/python/src/python_pkg_vhpi.vhd +++ b/vunit/vhdl/python/src/python_pkg_vhpi.vhd @@ -8,14 +8,30 @@ use std.textio.all; +use work.id_pkg.all; use work.integer_array_pkg.all; +use work.integer_vector_ptr_pkg.all; use work.logger_pkg.all; package python_ffi_pkg is - -- A session is a named Python namespace. Only the default session, the - -- __main__ namespace, is supported by this foreign language interface. - type python_session_t is array (positive range <>) of character; - constant default_session : python_session_t := "default"; + -- The identity of the Python interface, the parent of the session identities + constant p_python_id : id_t := get_id("vunit_lib:python"); + + -- A session is a named Python namespace, created from its name. Only the + -- default session, the __main__ namespace, is supported by this foreign + -- language interface. + type python_session_t is record + p_data : integer_vector_ptr_t; + end record; + + impure function new_session(name : string) return python_session_t; + impure function name(session : python_session_t) return string; + + -- The session the operations are performed in when no other one is given. + -- It is created the way new_session does, which cannot be called here since + -- its body is not elaborated until the package body is. + constant default_session : python_session_t := + (p_data => new_integer_vector_ptr(1, value => to_integer(get_id("default", parent => p_python_id)))); procedure python_setup; -- TODO: Looks like Riviera-PRO requires the path to the shared library to be fixed at compile time @@ -26,31 +42,31 @@ package python_ffi_pkg is function p_eval_integer(expr : string) return integer; attribute foreign of p_eval_integer : function is "VHPI libraries/python eval_integer"; - function eval_integer(expr : string; session : python_session_t := default_session) return integer; + impure function eval_integer(expr : string; session : python_session_t := default_session) return integer; alias eval is eval_integer[string, python_session_t return integer]; function p_eval_real(expr : string) return real; attribute foreign of p_eval_real : function is "VHPI libraries/python eval_real"; - function eval_real(expr : string; session : python_session_t := default_session) return real; + impure function eval_real(expr : string; session : python_session_t := default_session) return real; alias eval is eval_real[string, python_session_t return real]; function p_eval_integer_vector(expr : string) return integer_vector; attribute foreign of p_eval_integer_vector : function is "VHPI libraries/python eval_integer_vector"; - function eval_integer_vector( + impure function eval_integer_vector( expr : string; session : python_session_t := default_session ) return integer_vector; alias eval is eval_integer_vector[string, python_session_t return integer_vector]; function p_eval_real_vector(expr : string) return real_vector; attribute foreign of p_eval_real_vector : function is "VHPI libraries/python eval_real_vector"; - function eval_real_vector( + impure function eval_real_vector( expr : string; session : python_session_t := default_session ) return real_vector; alias eval is eval_real_vector[string, python_session_t return real_vector]; function p_eval_string(expr : string) return string; attribute foreign of p_eval_string : function is "VHPI libraries/python eval_string"; - function eval_string(expr : string; session : python_session_t := default_session) return string; + impure function eval_string(expr : string; session : python_session_t := default_session) return string; alias eval is eval_string[string, python_session_t return string]; procedure p_exec(code : string); @@ -66,7 +82,7 @@ package python_ffi_pkg is -- but they report a failure when they are used. -- Logger used to report Python errors - constant python_logger : logger_t := get_logger("vunit_lib:python"); + constant python_logger : logger_t := get_logger(p_python_id); -- Result kinds, must match vunit/python_bridge/runtime.py constant p_kind_integer : integer := 0; @@ -82,7 +98,7 @@ package python_ffi_pkg is constant p_kind_real_vector : integer := 10; -- Name of the operation, used in the error messages - function p_eval_operation(expr : string; session : python_session_t := default_session) return string; + impure function p_eval_operation(expr : string; session : python_session_t := default_session) return string; -- Execute the Python file with the given name impure function p_exec_file( @@ -111,26 +127,47 @@ package python_ffi_pkg is end package; package body python_ffi_pkg is + impure function new_session(name : string) return python_session_t is + begin + return (p_data => new_integer_vector_ptr(1, value => to_integer(get_id(name, parent => p_python_id)))); + end; + + -- The identity of a session, which is what two sessions are compared by + impure function p_id(session : python_session_t) return id_t is + begin + return to_id(get(session.p_data, 0)); + end; + + impure function name(session : python_session_t) return string is + begin + return name(p_id(session)); + end; + + impure function p_is_default(session : python_session_t) return boolean is + begin + return p_id(session) = p_id(default_session); + end; + procedure p_check_session(session : python_session_t) is begin - if session /= default_session then + if not p_is_default(session) then report "Python sessions are only supported with NVC, GHDL and Questa" severity failure; end if; end; - function eval_integer(expr : string; session : python_session_t := default_session) return integer is + impure function eval_integer(expr : string; session : python_session_t := default_session) return integer is begin p_check_session(session); return p_eval_integer(expr); end; - function eval_real(expr : string; session : python_session_t := default_session) return real is + impure function eval_real(expr : string; session : python_session_t := default_session) return real is begin p_check_session(session); return p_eval_real(expr); end; - function eval_integer_vector( + impure function eval_integer_vector( expr : string; session : python_session_t := default_session ) return integer_vector is begin @@ -138,7 +175,7 @@ package body python_ffi_pkg is return p_eval_integer_vector(expr); end; - function eval_real_vector( + impure function eval_real_vector( expr : string; session : python_session_t := default_session ) return real_vector is begin @@ -146,7 +183,7 @@ package body python_ffi_pkg is return p_eval_real_vector(expr); end; - function eval_string(expr : string; session : python_session_t := default_session) return string is + impure function eval_string(expr : string; session : python_session_t := default_session) return string is begin p_check_session(session); return p_eval_string(expr); @@ -166,12 +203,12 @@ package body python_ffi_pkg is failure(python_logger, name & " requires NVC, GHDL or Questa"); end; - function p_eval_operation(expr : string; session : python_session_t := default_session) return string is + impure function p_eval_operation(expr : string; session : python_session_t := default_session) return string is begin - if session = default_session then + if p_is_default(session) then return "eval(""" & expr & """)"; end if; - return "eval(""" & expr & """, session => """ & string(session) & """)"; + return "eval(""" & expr & """, session => """ & name(session) & """)"; end; impure function p_exec_file( diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd index 5806b471b..c6a0462ae 100644 --- a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -38,8 +38,13 @@ architecture tb of tb_python_pkg_bridge is "describe(v=__vunit__.error(""kwarg_unsigned cannot convert \""1010X010\""; the value has metavalues""))"; begin main : process - constant golden : python_session_t := "golden"; - constant fixed_point : python_session_t := "fixed_point"; + constant golden : python_session_t := new_session("golden"); + constant fixed_point : python_session_t := new_session("fixed_point"); + + -- Operations of a session other than the default one report on the logger + -- of the session, a child of python_logger + constant golden_logger : logger_t := get_logger("golden", python_logger); + constant fixed_point_logger : logger_t := get_logger("fixed_point", python_logger); variable arr, arr_b, result : integer_array_t; variable ptr : integer_vector_ptr_t; variable slv4 : std_ulogic_vector(3 downto 0); @@ -917,17 +922,17 @@ begin elsif run("Test that a name defined in another session is not visible") then define_error_helper; exec("def only_in_golden(): return 1", golden); - mock(python_logger, failure); + mock(fixed_point_logger, failure); discard_int := call("only_in_golden", session => fixed_point); check_only_log( - python_logger, + fixed_point_logger, "eval(""only_in_golden()"", session => ""fixed_point"") failed:" & LF & call_string( "expected_error", arg(string'("only_in_golden()")), arg(string'("")), arg(true) ), failure ); - unmock(python_logger); + unmock(fixed_point_logger); elsif run("Test executing a file in different sessions") then exec_file("models/counter.py", golden); @@ -956,15 +961,24 @@ begin elsif run("Test error context names the session") then define_error_helper; - mock(python_logger, failure); + mock(golden_logger, failure); exec("1 / 0", golden); check_only_log( - python_logger, + golden_logger, "exec(session => ""golden"") failed:" & LF & call_string("expected_error", arg(string'("1 / 0")), arg(string'(""))), failure ); - unmock(python_logger); + unmock(golden_logger); + + elsif run("Test that a session is an identity under the Python interface") then + check_equal(name(golden), "golden"); + check_equal(name(default_session), "default"); + check_equal(get_full_name(get_logger(name(golden), python_logger)), "vunit_lib:python:golden"); + -- The same name is the same session + check_equal(name(new_session("golden")), "golden"); + exec("session_marker = 1", golden); + check_equal(eval_integer("session_marker", new_session("golden")), 1); end if; end loop; From 6f34c380a160b0adae9a742c5044b1773aebf03a Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Mon, 14 Sep 2026 11:35:49 +0200 Subject: [PATCH 18/28] Give every session its own logger and follow the object conventions 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 --- docs/python_bridge/user_guide.rst | 91 +++++---- examples/vhdl/embedded_python/tb_example.vhd | 18 +- vunit/python_bridge/runtime.py | 9 +- vunit/vhdl/python/run.py | 6 +- .../vhdl/python/src/python_ffi_pkg_bridge.vhd | 81 ++++---- vunit/vhdl/python/src/python_pkg_vhpi.vhd | 32 +++- vunit/vhdl/python/test/tb_python_pkg.vhd | 66 ++++--- .../vhdl/python/test/tb_python_pkg_bridge.vhd | 181 +++++++++++++----- 8 files changed, 311 insertions(+), 173 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 83544f631..312519730 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -100,10 +100,9 @@ Every operation that runs Python code or evaluates a Python expression -- ``import_run_script`` and ``import_module_from_file`` -- takes an optional trailing parameter, ``session : python_session_t := default_session``, that selects the namespace -the operation runs in. A session is made from its name with ``new_session`` -and its Python namespace is created the first time it is used; the -``default_session`` constant used when the parameter is omitted runs in -``__main__``. +the operation runs in. A session is made with ``new_session`` and its Python +namespace is created the first time it is used; the ``default_session`` +constant used when the parameter is omitted runs in ``__main__``. .. code-block:: vhdl @@ -122,21 +121,40 @@ Since ``session`` comes after up to 10 positional arguments in ``call``, it is normally given by name, as above. Both sessions in the example can define ``model`` without interfering with each other. -The name of a session is a VUnit :ref:`identity ` under -``vunit_lib:python``, the identity of ``python_logger``, and ``name(session)`` -returns it. Two sessions made from the same name are the same session, so a -session does not have to be passed around to be used in several places. +A session is a VUnit object with an :ref:`identity ` of its own, +returned by ``get_id(session)``. ``new_session(name)`` creates the identity +``name`` under ``vunit_lib:python``, the identity of ``python_logger``, while +``new_session(id)`` takes an identity that already exists and can therefore +place the session anywhere in the identity tree, for example under the identity +of what it serves: -Errors of an operation performed in a session other than the default one are -reported on the logger of that session, ``get_logger(, -python_logger)``, which is a child of ``python_logger``. Mocking a logger does -not capture what its children log, which is why the default session reports on -``python_logger`` itself and why a test that mocks the errors of a session -mocks that session's logger: +.. code-block:: vhdl + + constant model_id : id_t := get_id("model", parent => get_id("my_testbench")); + constant model : python_session_t := new_session(model_id); + +Two sessions with the same identity are the same session, so a session does not +have to be passed around to be used in several places. ``name(session)`` is the +name of its identity without hierarchy, while the full name of the identity is +the key of its Python namespace. Two sessions with the same name but different +identities are therefore namespaces of their own. + +Errors of an operation are reported on the logger of the identity of the +session it was performed in, ``get_logger(get_id(session))``. The loggers of +the sessions created from a name are children of ``python_logger``, so log +levels and log handler settings made on ``python_logger`` apply to them, but +mocking a logger does not capture what its children log. A test that mocks the +errors of a session mocks the logger of that session, the default session +included: .. code-block:: vhdl - mock(get_logger("golden", python_logger), failure); + constant default_logger : logger_t := get_logger(get_id(default_session)); + constant golden_logger : logger_t := get_logger(get_id(golden)); + + ... + + mock(golden_logger, failure); On Riviera-PRO/Active-HDL (VHPI), only the default session is supported: passing any other session fails with a clear error. @@ -312,16 +330,16 @@ belongs to every character array type and an overload would therefore make ``std_ulogic``, ``unsigned`` or ``signed`` value; any other metavalue is an error. -A value that cannot be converted is reported as a failure on -``python_logger``, and the argument becomes an expression raising the same -message in Python. The call it is used in therefore fails with that message as -well instead of being made without the argument, which is what would otherwise -happen when the logger is mocked and the simulation continues: +A value that cannot be converted has no session and is reported as a failure +on ``python_logger`` itself. The argument becomes an expression raising the +same message in Python. The call it is used in therefore fails with that +message as well instead of being made without the argument, which is what would +otherwise happen when the logger is mocked and the simulation continues: .. code-block:: text FAILURE - vunit_lib:python - arg cannot convert 'X'; expected '0', '1', 'L' or 'H' - FAILURE - vunit_lib:python - eval("model(__vunit__.error("arg cannot convert 'X'; ..."))") failed: + FAILURE - vunit_lib:python:default - eval("model(__vunit__.error("arg cannot convert 'X'; ..."))") failed: Traceback (most recent call last): ... RuntimeError: arg cannot convert 'X'; expected '0', '1', 'L' or 'H' @@ -474,15 +492,19 @@ Errors ------ Python exceptions, syntax errors, type errors and undefined names are -reported as failures on the ``python_logger`` logger (named -``vunit_lib:python``), including the Python traceback. The test fails and -stops like for any other failure. The logger can be mocked to test error -handling; when mocked, ``eval``/``call`` return a default value (``0``, -``0.0``, ``""`` or an empty vector, depending on the type) after the failure. +reported as failures on the logger of the :ref:`session +` the operation was performed in, +``get_logger(get_id(session))``, including the Python traceback. For the +default session that logger is named ``vunit_lib:python:default`` and +``python_logger`` (named ``vunit_lib:python``) is its parent. The test fails +and stops like for any other failure. The logger of the session can be mocked +to test error handling; when mocked, ``eval``/``call`` return a default value +(``0``, ``0.0``, ``""`` or an empty vector, depending on the type) after the +failure. .. code-block:: text - FAILURE - vunit_lib:python - eval("1 / 0") failed: + FAILURE - vunit_lib:python:default - eval("1 / 0") failed: Traceback (most recent call last): File "", line 1, in 1 / 0 @@ -564,8 +586,9 @@ Semantics to be aware of * ``eval``/``eval_`` are declared ``impure`` since they always read state from the Python interpreter. - * Errors are reported as failures on the ``python_logger`` logger, - including the Python traceback, rather than aborting the simulation. + * Errors are reported as failures on the logger of the :ref:`session + `, including the Python traceback, rather than + aborting the simulation. * ``python_setup`` and ``python_cleanup`` are optional, see :ref:`python_bridge:setup_and_cleanup`. ``python_cleanup`` does not finalize the interpreter. @@ -597,8 +620,8 @@ default session exists, the operations implemented by the bridge (``integer_array_t`` values, the ``boolean``, ``std_ulogic``, vector and ``integer_array_t`` results, ``exec_file``) report that they require NVC, GHDL or Questa, a Python error stops the simulation with the message printed by the -application rather than through ``python_logger``, and ``real`` values outside -the single precision float range are rejected. +application rather than through the logger of the session, and ``real`` values +outside the single precision float range are rejected. See :vunit_example:`➚ examples/vhdl/embedded_python ` for a complete example covering all three simulator families. @@ -610,9 +633,9 @@ arguments, a 20 register status dump, wide ``integer_array_t`` image transposed by NumPy, the result types of ``eval`` and ``call``, Python files executed with ``exec_file`` or imported with ``import_module_from_file``, two models loaded into a session each, and a -Python model failing with ``python_logger`` mocked. Its last test case drives -``python_model``, a verification component whose behaviour is the Python -function in ``python_model.py`` rather than VHDL. +Python model failing with the logger of the default session mocked. Its last +test case drives ``python_model``, a verification component whose behaviour is +the Python function in ``python_model.py`` rather than VHDL. .. automodule:: vunit.python_bridge.foreign_application diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index f686ef796..e6a1bf071 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -55,6 +55,11 @@ begin constant golden : python_session_t := new_session("golden"); constant fixed_point : python_session_t := new_session("fixed_point"); + -- The operations of a session report on the logger of the identity of the + -- session. python_logger is the parent of these loggers, so it is the one + -- to configure, while it is the logger of the session that is mocked. + constant default_logger : logger_t := get_logger(get_id(default_session)); + variable cycles : unsigned(63 downto 0) := x"123456789ABCDEF0"; variable value : unsigned(31 downto 0) := x"DEADBEEF"; variable offset : signed(15 downto 0) := to_signed(-12345, 16); @@ -456,16 +461,17 @@ begin vhdl_integer := eval("1 / 0"); -- Division by zero exception elsif run("Test error handling of a Python model") then - -- The failures above are reported on python_logger and that logger can be mocked. - -- That is how a model is tested for what it does with invalid input, here an - -- empty sine table, without the test failing. + -- The failures above are reported on the logger of the session the failing + -- operation was performed in, here the default session, and that logger can be + -- mocked. That is how a model is tested for what it does with invalid input, + -- here an empty sine table, without the test failing. import_device_model; exec("from device_model import sine_table, failure_text"); - mock(python_logger, failure); + mock(default_logger, failure); table := call_integer_vector_ptr("sine_table", arg(0)); check_only_log( - python_logger, + default_logger, -- The message is the failing operation and the Python traceback, ending with -- the text of the exception. failure_text reproduces the traceback by -- evaluating the same expression; the source name in the traceback is the one @@ -477,7 +483,7 @@ begin ), failure ); - unmock(python_logger); + unmock(default_logger); ------------------------------------------------------------------------------------- -- Examples related to the simulation environment diff --git a/vunit/python_bridge/runtime.py b/vunit/python_bridge/runtime.py index 4e49975b2..3ca6b1db5 100644 --- a/vunit/python_bridge/runtime.py +++ b/vunit/python_bridge/runtime.py @@ -58,7 +58,9 @@ TEXT_ENCODING = "utf-8" TEXT_ERRORS = "surrogateescape" -DEFAULT_SESSION = "default" +# Key of the default session namespace. A session is keyed by the full name of +# its VUnit identity, see default_session in python_ffi_pkg. +DEFAULT_SESSION = "vunit_lib:python:default" # Name of the bridge object injected into every session namespace. HANDLE_NAME = "__vunit__" @@ -285,7 +287,10 @@ def cleanup(self): def select_session(self, name): """ - Make the namespace of a session current, creating it on first use. + Make the namespace of a session current, creating it on first use. The + name is the full name of the VUnit identity of the session, which makes + two sessions with the same name but different identities namespaces of + their own. """ if name not in self._sessions: self._sessions[name] = { diff --git a/vunit/vhdl/python/run.py b/vunit/vhdl/python/run.py index 8c9e136f9..cd70ec840 100644 --- a/vunit/vhdl/python/run.py +++ b/vunit/vhdl/python/run.py @@ -17,9 +17,9 @@ ROOT = Path(__file__).parent # The VHPI application reports Python errors fatally rather than as failures -# on python_logger, so the negative tests of tb_python_pkg cannot mock the -# logger there and fail by design. The Python bridge (NVC, GHDL, Questa) makes -# them observable from VHDL and there the whole testbench passes. +# on the logger of the session, so the negative tests of tb_python_pkg cannot +# mock that logger there and fail by design. The Python bridge (NVC, GHDL, +# Questa) makes them observable from VHDL and there the whole testbench passes. EXPECTED_FAILURES_VHPI = [ "lib.tb_python_pkg.Test eval of integer with overflow from Python to C", "lib.tb_python_pkg.Test eval of integer with underflow from Python to C", diff --git a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd index a38738453..0e75d73f8 100644 --- a/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd +++ b/vunit/vhdl/python/src/python_ffi_pkg_bridge.vhd @@ -12,8 +12,9 @@ -- -- Deviations from the other variants: -- * The subprograms are impure since they call impure foreign subprograms. --- * Errors are reported as failures on python_logger rather than aborting --- the simulation directly, which makes them observable from VHDL. +-- * Errors are reported as failures on the logger of the session they were +-- performed in rather than aborting the simulation directly, which makes +-- them observable from VHDL. -- * Sessions other than the default one are supported. -- -- The p_ prefixed declarations are the private primitives the bridge @@ -28,29 +29,43 @@ use work.logger_pkg.all; use work.python_bridge_pkg.all; package python_ffi_pkg is - -- The identity of the Python interface. It is the parent of the session - -- identities and of python_logger. + -- The identity of the Python interface. It is the identity of + -- python_logger and the parent of the identities of the sessions created + -- from a name. constant p_python_id : id_t := get_id("vunit_lib:python"); - -- Logger used to report Python errors, for example exceptions with their - -- traceback. An operation performed in a session other than the default one - -- reports on the logger of that session, get_logger(, - -- python_logger), which is a child of python_logger. + -- Logger of the Python interface. It is the parent of the session loggers + -- an operation reports its errors on, so log levels and log handler + -- settings made on python_logger apply to all sessions. It is also the + -- logger used to report what has no session of its own: an argument value + -- that cannot be converted to Python and the transfer of an integer_array_t. constant python_logger : logger_t := get_logger(p_python_id); - -- A session is a named Python namespace. Sessions are isolated from each - -- other and are created from their name, which is an identity under the - -- identity of the Python interface: + -- A session is a Python namespace. Sessions are isolated from each other + -- and have an identity, which is what they are created from: -- -- constant golden_model : python_session_t := new_session("golden_model"); -- - -- The Python namespace is created on first use. The default session is the - -- __main__ namespace. + -- new_session(name) creates the identity name under the identity of the + -- Python interface, while new_session(id) takes an existing identity, which + -- puts the session anywhere in the identity tree, for example under the + -- identity of what it serves. Two sessions with the same identity are the + -- same session. + -- + -- The Python namespace is created on first use and the operations of a + -- session report their errors on get_logger(get_id(session)). The default + -- session is the __main__ namespace. type python_session_t is record p_data : integer_vector_ptr_t; end record; impure function new_session(name : string) return python_session_t; + impure function new_session(id : id_t) return python_session_t; + + -- The identity of a session, which is what two sessions are compared by + impure function get_id(session : python_session_t) return id_t; + + -- The name of the identity of a session, without hierarchy impure function name(session : python_session_t) return string; -- The session the operations are performed in when no other one is given. @@ -107,9 +122,6 @@ package python_ffi_pkg is constant p_kind_integer_vector : integer := 9; constant p_kind_real_vector : integer := 10; - -- The logger the operations of a session report on - impure function p_logger(session : python_session_t := default_session) return logger_t; - -- Names of the operations, used in the error messages impure function p_exec_operation(session : python_session_t := default_session) return string; impure function p_exec_file_operation( @@ -174,34 +186,27 @@ package body python_ffi_pkg is ----------------------------------------------------------------------------- impure function new_session(name : string) return python_session_t is begin - return (p_data => new_integer_vector_ptr(1, value => to_integer(get_id(name, parent => p_python_id)))); + return new_session(get_id(name, parent => p_python_id)); end; - -- The identity of a session, which is what two sessions are compared by - impure function p_id(session : python_session_t) return id_t is + impure function new_session(id : id_t) return python_session_t is begin - return to_id(get(session.p_data, 0)); + return (p_data => new_integer_vector_ptr(1, value => to_integer(id))); end; - impure function name(session : python_session_t) return string is + impure function get_id(session : python_session_t) return id_t is begin - return name(p_id(session)); + return to_id(get(session.p_data, 0)); end; - impure function p_is_default(session : python_session_t) return boolean is + impure function name(session : python_session_t) return string is begin - return p_id(session) = p_id(default_session); + return name(get_id(session)); end; - -- Mocking a logger does not capture the logs of its children, so the - -- operations of the default session report on python_logger itself rather - -- than on a logger of their own. - impure function p_logger(session : python_session_t := default_session) return logger_t is + impure function p_is_default(session : python_session_t) return boolean is begin - if p_is_default(session) then - return python_logger; - end if; - return get_logger(p_id(session)); + return get_id(session) = get_id(default_session); end; ----------------------------------------------------------------------------- @@ -278,10 +283,14 @@ package body python_ffi_pkg is return status; end; + -- The full name of the identity of the session is the key of its Python + -- namespace, so that two sessions with the same name but different + -- identities are namespaces of their own. impure function p_begin(session : python_session_t; operation : string) return boolean is - constant logger : logger_t := p_logger(session); + constant logger : logger_t := get_logger(get_id(session)); begin - return p_succeeded(p_send(name(session)), operation, logger) and p_succeeded(vpy_begin, operation, logger); + return p_succeeded(p_send(full_name(get_id(session))), operation, logger) + and p_succeeded(vpy_begin, operation, logger); end; impure function p_exec( @@ -290,7 +299,7 @@ package body python_ffi_pkg is operation : string; session : python_session_t := default_session ) return boolean is - constant logger : logger_t := p_logger(session); + constant logger : logger_t := get_logger(get_id(session)); begin return p_begin(session, operation) and p_succeeded(p_send(text), operation, logger) @@ -311,7 +320,7 @@ package body python_ffi_pkg is operation : string; session : python_session_t := default_session ) return boolean is - constant logger : logger_t := p_logger(session); + constant logger : logger_t := get_logger(get_id(session)); begin return p_begin(session, operation) and p_succeeded(p_send(expr), operation, logger) diff --git a/vunit/vhdl/python/src/python_pkg_vhpi.vhd b/vunit/vhdl/python/src/python_pkg_vhpi.vhd index 1a04b8f59..3fbbe81b1 100644 --- a/vunit/vhdl/python/src/python_pkg_vhpi.vhd +++ b/vunit/vhdl/python/src/python_pkg_vhpi.vhd @@ -14,17 +14,25 @@ use work.integer_vector_ptr_pkg.all; use work.logger_pkg.all; package python_ffi_pkg is - -- The identity of the Python interface, the parent of the session identities + -- The identity of the Python interface, the identity of python_logger and + -- the parent of the identities of the sessions created from a name constant p_python_id : id_t := get_id("vunit_lib:python"); - -- A session is a named Python namespace, created from its name. Only the - -- default session, the __main__ namespace, is supported by this foreign - -- language interface. + -- A session is a Python namespace with an identity, created from a name + -- under the identity of the Python interface or from an identity of its + -- own. Only the default session, the __main__ namespace, is supported by + -- this foreign language interface. type python_session_t is record p_data : integer_vector_ptr_t; end record; impure function new_session(name : string) return python_session_t; + impure function new_session(id : id_t) return python_session_t; + + -- The identity of a session, which is what two sessions are compared by + impure function get_id(session : python_session_t) return id_t; + + -- The name of the identity of a session, without hierarchy impure function name(session : python_session_t) return string; -- The session the operations are performed in when no other one is given. @@ -81,7 +89,7 @@ package python_ffi_pkg is -- below are declared so that python_pkg has one body for every simulator, -- but they report a failure when they are used. - -- Logger used to report Python errors + -- Logger of the Python interface, the parent of the session loggers constant python_logger : logger_t := get_logger(p_python_id); -- Result kinds, must match vunit/python_bridge/runtime.py @@ -129,23 +137,27 @@ end package; package body python_ffi_pkg is impure function new_session(name : string) return python_session_t is begin - return (p_data => new_integer_vector_ptr(1, value => to_integer(get_id(name, parent => p_python_id)))); + return new_session(get_id(name, parent => p_python_id)); end; - -- The identity of a session, which is what two sessions are compared by - impure function p_id(session : python_session_t) return id_t is + impure function new_session(id : id_t) return python_session_t is + begin + return (p_data => new_integer_vector_ptr(1, value => to_integer(id))); + end; + + impure function get_id(session : python_session_t) return id_t is begin return to_id(get(session.p_data, 0)); end; impure function name(session : python_session_t) return string is begin - return name(p_id(session)); + return name(get_id(session)); end; impure function p_is_default(session : python_session_t) return boolean is begin - return p_id(session) = p_id(default_session); + return get_id(session) = get_id(default_session); end; procedure p_check_session(session : python_session_t) is diff --git a/vunit/vhdl/python/test/tb_python_pkg.vhd b/vunit/vhdl/python/test/tb_python_pkg.vhd index 58fff8621..4b5a657a5 100644 --- a/vunit/vhdl/python/test/tb_python_pkg.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg.vhd @@ -23,6 +23,10 @@ architecture tb of tb_python_pkg is "(" & integer'image(integer'low) & " to " & integer'image(integer'high) & ")"; begin test_runner : process + -- The operations of a session report on the logger of the identity of the + -- session, python_logger being the parent of the session loggers + constant default_logger : logger_t := get_logger(get_id(default_session)); + constant empty_integer_vector : integer_vector(0 downto 1) := (others => 0); constant empty_real_vector : real_vector(0 downto 1) := (others => 0.0); constant test_real_vector : real_vector := (-3.4028234664e38, -1.9, 0.0, 1.1, -3.4028234664e38); @@ -74,48 +78,48 @@ begin check_equal(eval("-2**31"), integer'low); elsif run("Test eval of integer with overflow from Python to C") then - mock(python_logger, failure); + mock(default_logger, failure); vhdl_int := eval("2**63"); check_only_log( - python_logger, + default_logger, "eval(""2**63"") failed:" & LF & "OverflowError: 9223372036854775808 is outside the range of VHDL integer " & integer_range, failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test eval of integer with underflow from Python to C") then - mock(python_logger, failure); + mock(default_logger, failure); vhdl_int := eval("-2**63 - 1"); check_only_log( - python_logger, + default_logger, "eval(""-2**63 - 1"") failed:" & LF & "OverflowError: -9223372036854775809 is outside the range of VHDL integer " & integer_range, failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test eval of integer with overflow from C to VHDL") then - mock(python_logger, failure); + mock(default_logger, failure); vhdl_int := eval("2**31"); check_only_log( - python_logger, + default_logger, "eval(""2**31"") failed:" & LF & "OverflowError: 2147483648 is outside the range of VHDL integer " & integer_range, failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test eval of integer with underflow from C to VHDL") then - mock(python_logger, failure); + mock(default_logger, failure); vhdl_int := eval("-2**31 - 1"); check_only_log( - python_logger, + default_logger, "eval(""-2**31 - 1"") failed:" & LF & "OverflowError: -2147483649 is outside the range of VHDL integer " & integer_range, failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test eval of real expression") then check_equal(eval("3.40282346e38"), 3.40282346e38); @@ -129,35 +133,35 @@ begin check_equal(eval("1.0e300"), 1.0e300); -- A value that is not finite has no VHDL real representation - mock(python_logger, failure); + mock(default_logger, failure); vhdl_real := eval("float('inf')"); check_only_log( - python_logger, + default_logger, "eval(""float('inf')"") failed:" & LF & "ValueError: inf cannot be represented as VHDL real", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test eval of real with underflow from C to VHDL") then check_equal(eval("-1.0e300"), -1.0e300); - mock(python_logger, failure); + mock(default_logger, failure); vhdl_real := eval("-float('inf')"); check_log( - python_logger, + default_logger, "eval(""-float('inf')"") failed:" & LF & "ValueError: -inf cannot be represented as VHDL real", failure ); vhdl_real := eval("float('nan')"); check_only_log( - python_logger, + default_logger, "eval(""float('nan')"") failed:" & LF & "ValueError: nan cannot be represented as VHDL real", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test converting integer_vector to Python list string") then check_equal(to_py_list_str(empty_integer_vector), "[]"); @@ -322,51 +326,51 @@ begin "doing_something_wrong = doing_something_right_misspelled'" ); - mock(python_logger, failure); + mock(default_logger, failure); exec( "doing_something_right = 17" & LF & "doing_something_wrong = doing_something_right_misspelled" ); check_only_log( - python_logger, + default_logger, "exec failed:" & LF & eval_string("expected_error(failing_source, '')"), failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test exceptions in eval") then define_error_helper; - mock(python_logger, failure); + mock(default_logger, failure); vhdl_int := eval("1 / 0"); check_only_log( - python_logger, + default_logger, "eval(""1 / 0"") failed:" & LF & call_string( "expected_error", arg(string'("1 / 0")), arg(string'("")), arg(true) ), failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test eval with type error") then - mock(python_logger, failure); + mock(default_logger, failure); vhdl_int := eval("10 / 2"); check_only_log( - python_logger, + default_logger, "eval(""10 / 2"") failed:" & LF & "TypeError: Cannot convert Python float (5.0) to VHDL integer; expected int", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test raising exception") then define_error_helper; - mock(python_logger, failure); + mock(default_logger, failure); exec("raise RuntimeError('An exception')"); check_only_log( - python_logger, + default_logger, "exec failed:" & LF & call_string( "expected_error", @@ -375,7 +379,7 @@ begin ), failure ); - unmock(python_logger); + unmock(default_logger); --------------------------------------------------------------------- -- Misc tests diff --git a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd index c6a0462ae..e33344230 100644 --- a/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd +++ b/vunit/vhdl/python/test/tb_python_pkg_bridge.vhd @@ -41,10 +41,21 @@ begin constant golden : python_session_t := new_session("golden"); constant fixed_point : python_session_t := new_session("fixed_point"); - -- Operations of a session other than the default one report on the logger - -- of the session, a child of python_logger - constant golden_logger : logger_t := get_logger("golden", python_logger); - constant fixed_point_logger : logger_t := get_logger("fixed_point", python_logger); + -- A session created from an identity of its own, placed outside of the + -- identity of the Python interface + constant model : python_session_t := new_session(get_id("tb_python_pkg_bridge:model")); + + -- Two sessions with the same name but different identities + constant left_model : python_session_t := new_session(get_id("left:model")); + constant right_model : python_session_t := new_session(get_id("right:model")); + + -- The operations of a session report on the logger of the identity of the + -- session, python_logger being the parent of the loggers of the sessions + -- created from a name + constant default_logger : logger_t := get_logger(get_id(default_session)); + constant golden_logger : logger_t := get_logger(get_id(golden)); + constant fixed_point_logger : logger_t := get_logger(get_id(fixed_point)); + variable arr, arr_b, result : integer_array_t; variable ptr : integer_vector_ptr_t; variable slv4 : std_ulogic_vector(3 downto 0); @@ -210,30 +221,30 @@ begin elsif run("Test that exec_file with a missing file fails") then -- The error message shows the path in the native format of the OS exec("import os" + "def native_repr(path):" + " return repr(os.path.normpath(path))"); - mock(python_logger, failure); + mock(default_logger, failure); exec_file("models/does_not_exist.py"); check_only_log( - python_logger, + default_logger, "exec_file(""" & join(tb_path(runner_cfg), "models/does_not_exist.py") & """) failed:" & LF & "FileNotFoundError: [Errno 2] No such file or directory: " & call_string("native_repr", arg(string'(join(tb_path(runner_cfg), "models/does_not_exist.py")))), failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that a Python exception with traceback logs a failure") then define_error_helper; - mock(python_logger, failure); + mock(default_logger, failure); exec("raise ValueError('boom')"); check_only_log( - python_logger, + default_logger, "exec failed:" & LF & call_string( "expected_error", arg(string'("raise ValueError('boom')")), arg(string'("")) ), failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that operations work after python_cleanup") then exec("cleanup_marker = 1"); @@ -301,15 +312,15 @@ begin check_equal(eval_real("1 / 3"), 1.0 / 3.0); elsif run("Test that eval of real rejects an int") then - mock(python_logger, failure); + mock(default_logger, failure); check_equal(eval_real("1"), 0.0); check_only_log( - python_logger, + default_logger, "eval(""1"") failed:" & LF & "TypeError: Cannot convert Python int (1) to VHDL real; expected float", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test boolean round trip") then exec("def identity(x):" + " return x"); @@ -376,10 +387,10 @@ begin check_equal(get(ptr, 2), 1); elsif run("Test that eval of integer_vector is strict about element types") then - mock(python_logger, failure); + mock(default_logger, failure); discard(eval_integer_vector("[1, 2.0]")); check_log( - python_logger, + default_logger, "eval(""[1, 2.0]"") failed:" & LF & "TypeError: Cannot convert element 1 of the Python list, a float (2.0), " & "to a VHDL integer; expected int", @@ -387,7 +398,7 @@ begin ); discard(eval_integer_vector("[True]")); check_log( - python_logger, + default_logger, "eval(""[True]"") failed:" & LF & "TypeError: Cannot convert element 0 of the Python list, a bool (True), " & "to a VHDL integer; expected int", @@ -395,107 +406,107 @@ begin ); discard(eval_integer_vector("17")); check_only_log( - python_logger, + default_logger, "eval(""17"") failed:" & LF & "TypeError: Cannot convert Python int (17) to VHDL integer_vector; expected a list or tuple", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that eval of real_vector is strict about element types") then reals := eval_real_vector("(1.5, -0.25, 0.0)"); check_equal(reals(1), -0.25); - mock(python_logger, failure); + mock(default_logger, failure); discard(eval_real_vector("[1.0, 2]")); check_log( - python_logger, + default_logger, "eval(""[1.0, 2]"") failed:" & LF & "TypeError: Cannot convert element 1 of the Python list, a int (2), to a VHDL real; expected float", failure ); discard(eval_real_vector("[True]")); check_only_log( - python_logger, + default_logger, "eval(""[True]"") failed:" & LF & "TypeError: Cannot convert element 0 of the Python list, a bool (True), to a VHDL real; expected float", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that a wrong return type fails") then exec("def returns_none():" + " return None"); - mock(python_logger, failure); + mock(default_logger, failure); discard_int := call("returns_none"); check_only_log( - python_logger, + default_logger, "eval(""returns_none()"") failed:" & LF & "TypeError: Cannot convert Python NoneType (None) to VHDL integer; expected int", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that integer overflow fails") then exec("def too_big():" + " return 2**31"); - mock(python_logger, failure); + mock(default_logger, failure); discard_int := call("too_big"); check_only_log( - python_logger, + default_logger, "eval(""too_big()"") failed:" & LF & "OverflowError: 2147483648 is outside the range of VHDL integer (-2147483648 to 2147483647)", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that signed overflow in a procedure result fails") then exec("def identity(x):" + " return x"); - mock(python_logger, failure); + mock(default_logger, failure); call_signed("identity", s8, arg(200)); check_only_log( - python_logger, + default_logger, "eval(""identity(200)"") failed:" & LF & "OverflowError: 200 does not fit in a 8 bit signed (-128 to 127)", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that unsigned overflow in a procedure result fails") then exec("def identity(x):" + " return x"); - mock(python_logger, failure); + mock(default_logger, failure); call_unsigned("identity", u8, arg(-1)); check_only_log( - python_logger, + default_logger, "eval(""identity(-1)"") failed:" & LF & "OverflowError: -1 does not fit in a 8 bit unsigned (0 to 255)", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that a std_ulogic_vector length mismatch in a procedure result fails") then exec("def wrong_length_bits():" + " return '01011'"); - mock(python_logger, failure); + mock(default_logger, failure); call_std_ulogic_vector("wrong_length_bits", slv4); check_only_log( - python_logger, + default_logger, "eval(""wrong_length_bits()"") failed:" & LF & "ValueError: Got 5 std_ulogic values but the VHDL result has length 4", failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that an undefined function fails") then define_error_helper; - mock(python_logger, failure); + mock(default_logger, failure); discard_int := call("no_such_function"); check_only_log( - python_logger, + default_logger, "eval(""no_such_function()"") failed:" & LF & call_string( "expected_error", arg(string'("no_such_function()")), arg(string'("")), arg(true) ), failure ); - unmock(python_logger); + unmock(default_logger); --------------------------------------------------------------------- -- Argument values @@ -603,31 +614,31 @@ begin elsif run("Test that a repeated keyword in a group is a Python error") then define_error_helper; - mock(python_logger, failure); + mock(default_logger, failure); check_equal(call_string("describe", kwarg("a", 1) & kwarg("a", 2)), ""); check_only_log( - python_logger, + default_logger, "eval(""describe(**dict(a=1, a=2))"") failed:" & LF & call_string( "expected_error", arg(string'("describe(**dict(a=1, a=2))")), arg(string'("")), arg(true) ), failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test that a keyword argument group before a positional argument is a Python error") then define_error_helper; - mock(python_logger, failure); + mock(default_logger, failure); check_equal(call_string("describe", kwarg("a", 1) & kwarg("b", 2), arg(3)), ""); check_only_log( - python_logger, + default_logger, "eval(""describe(**dict(a=1, b=2), 3)"") failed:" & LF & call_string( "expected_error", arg(string'("describe(**dict(a=1, b=2), 3)")), arg(string'("")), arg(true) ), failure ); - unmock(python_logger); + unmock(default_logger); elsif run("Test a positional argument group") then define_describe; @@ -734,13 +745,16 @@ begin -- without the argument define_describe; define_error_helper; + -- The value is reported on python_logger, which has no session, and + -- the call it is used in on the logger of the session it is made in mock(python_logger, failure); + mock(default_logger, failure); check_equal(call_string("describe", arg('X')), ""); check_log(python_logger, std_ulogic_arg_error, failure); exec("failing_source = r" & py_quotes & std_ulogic_arg_call & py_quotes); check_log( - python_logger, + default_logger, "eval(""" & std_ulogic_arg_call & """) failed:" & LF & eval_string("expected_error(failing_source, '', True)"), failure @@ -751,12 +765,13 @@ begin check_log(python_logger, unsigned_arg_error, failure); exec("failing_source = r" & py_quotes & unsigned_arg_call & py_quotes); check_only_log( - python_logger, + default_logger, "eval(""" & unsigned_arg_call & """) failed:" & LF & eval_string("expected_error(failing_source, '', True)"), failure ); unmock(python_logger); + unmock(default_logger); elsif run("Test keyword forms of the typed argument values") then define_describe; @@ -928,7 +943,10 @@ begin fixed_point_logger, "eval(""only_in_golden()"", session => ""fixed_point"") failed:" & LF & call_string( - "expected_error", arg(string'("only_in_golden()")), arg(string'("")), arg(true) + "expected_error", + arg(string'("only_in_golden()")), + arg(string'("")), + arg(true) ), failure ); @@ -966,7 +984,9 @@ begin check_only_log( golden_logger, "exec(session => ""golden"") failed:" & LF & - call_string("expected_error", arg(string'("1 / 0")), arg(string'(""))), + call_string( + "expected_error", arg(string'("1 / 0")), arg(string'("")) + ), failure ); unmock(golden_logger); @@ -974,12 +994,71 @@ begin elsif run("Test that a session is an identity under the Python interface") then check_equal(name(golden), "golden"); check_equal(name(default_session), "default"); - check_equal(get_full_name(get_logger(name(golden), python_logger)), "vunit_lib:python:golden"); + check(get_id(golden) = get_id("golden", parent => p_python_id)); + check_equal(full_name(get_id(golden)), "vunit_lib:python:golden"); + check_equal(get_full_name(get_logger(get_id(golden))), "vunit_lib:python:golden"); -- The same name is the same session check_equal(name(new_session("golden")), "golden"); exec("session_marker = 1", golden); check_equal(eval_integer("session_marker", new_session("golden")), 1); + elsif run("Test a session created from an identity of its own") then + -- An identity that is not under the identity of the Python interface + -- places the session elsewhere in the identity tree + check_equal(name(model), "model"); + check_equal(full_name(get_id(model)), "tb_python_pkg_bridge:model"); + check_equal(get_full_name(get_logger(get_id(model))), "tb_python_pkg_bridge:model"); + -- The same identity is the same session + check(get_id(new_session(get_id("tb_python_pkg_bridge:model"))) = get_id(model)); + + -- and its namespace is one of its own + exec("session_marker = 1", model); + exec("session_marker = 2"); + check_equal(eval_integer("session_marker", model), 1); + check_equal(eval_integer("session_marker"), 2); + + elsif run("Test that sessions with the same name but different identities differ") then + check_equal(name(left_model), name(right_model)); + check(get_id(left_model) /= get_id(right_model)); + + exec("session_marker = 1", left_model); + exec("session_marker = 2", right_model); + check_equal(eval_integer("session_marker", left_model), 1); + check_equal(eval_integer("session_marker", right_model), 2); + + elsif run("Test that a failure is reported on the logger of its session") then + define_error_helper; + -- python_logger is the parent of the loggers the sessions created from + -- a name report on, but mocking a logger does not capture what its + -- children log. An operation that fails is therefore caught by mocking + -- the logger of the session it is performed in, the default session + -- included. + mock(python_logger, failure); + mock(default_logger, failure); + mock(golden_logger, failure); + + exec("1 / 0", golden); + check_only_log( + golden_logger, + "exec(session => ""golden"") failed:" & LF & + call_string( + "expected_error", arg(string'("1 / 0")), arg(string'("")) + ), + failure + ); + + exec("1 / 0"); + check_only_log( + default_logger, + "exec failed:" & LF & + call_string("expected_error", arg(string'("1 / 0")), arg(string'(""))), + failure + ); + + unmock(python_logger); + unmock(default_logger); + unmock(golden_logger); + end if; end loop; From 4a3e31b8ef7a7907d4002b7a2645bdb6e837830e Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 00:31:50 +0200 Subject: [PATCH 19/28] Simplify the Python bridge without changing its behaviour * The package body of python_bridge_pkg is generated from the declarations of the template instead of being written by hand. * NumPy is imported unconditionally by the runtime since it is a dependency of VUnit, which also removes the staged value class and the runtime setup call that only flushed. * The result converters are looked up by the VHDL type name. * One compile helper serves the POSIX, Windows FLI and VHPI builds, and the Python.h check of the Windows FLI build reuses the POSIX one. * The foreign attribute strings are format strings rather than closures, the bridge is a named tuple and the unused parameters of bridge_sources and windows_dll_name are gone. * The C configuration parser uses a key table and the error text helpers use snprintf. * The unit tests share their simulator fixtures and the token tests are one loop. The sdist check of the workflow checks the C sources, the DLLs and the absence of Linux binaries only. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/python_bridge.yml | 8 +- tests/unit/test_python_bridge.py | 224 +++++++----------- vunit/python_bridge/bridge.py | 82 +++---- vunit/python_bridge/foreign_application.py | 44 +--- vunit/python_bridge/native/config.c | 29 +-- vunit/python_bridge/native/error.c | 18 +- vunit/python_bridge/native/interpreter.c | 16 +- vunit/python_bridge/native/operations.c | 13 +- vunit/python_bridge/native_library.py | 71 ++---- vunit/python_bridge/runtime.py | 126 ++-------- vunit/python_bridge/simulator_hooks.py | 4 +- .../vhdl/python/src/python_bridge_pkg.vhd.in | 112 +-------- 12 files changed, 213 insertions(+), 534 deletions(-) diff --git a/.github/workflows/python_bridge.yml b/.github/workflows/python_bridge.yml index 77ae5b72b..e19376849 100644 --- a/.github/workflows/python_bridge.yml +++ b/.github/workflows/python_bridge.yml @@ -205,14 +205,10 @@ jobs: pip install -U setuptools wheel --progress-bar off python setup.py sdist - # Sources of every kind, the five DLLs, no Linux binary + # The C sources, the five DLLs, no Linux binary - name: '🔍 Check sdist content' run: | tar tzf dist/*.tar.gz > content.txt - for file in python_bridge/runtime.py python_bridge/native/bridge.h vhdl/python/src/python_bridge_pkg.vhd.in \ - vhdl/python/src/python_pkg.vhd vhdl/python/src/python_pkg_vhpi.c \ - python_bridge/foreign_application.py; do - grep -q "vunit/$file\$" content.txt || { echo "missing $file"; exit 1; } - done + grep -q 'vunit/python_bridge/native/bridge.h$' content.txt test "$(grep -c 'vunit/python_bridge/bin/vunit_python_bridge-cp3[0-9]*-win_amd64.dll$' content.txt)" = 5 ! grep -q '\.so$' content.txt diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py index a923549ce..8bbfd7782 100644 --- a/tests/unit/test_python_bridge.py +++ b/tests/unit/test_python_bridge.py @@ -182,12 +182,15 @@ def test_vhpi_adds_python_pkg_vhpi_and_builds_the_application(self): simulator = self._simulator_with_flis("rivierapro", {"VHPI"}) builtins = self._builtins(simulator=simulator) builtins.add_vhdl_builtins() - with mock.patch("vunit.python_bridge.bridge.setup") as setup_mock, mock.patch( - "vunit.builtins.setup_vhpi_application" - ) as vhpi_mock: + with ( + mock.patch("vunit.python_bridge.bridge.setup") as setup_mock, + mock.patch("vunit.builtins.setup_vhpi_application") as vhpi_mock, + ): builtins.add("python") setup_mock.assert_not_called() - vhpi_mock.assert_called_once_with(builtins._vunit_obj._output_path, simulator) # pylint: disable=protected-access + vhpi_mock.assert_called_once_with( + builtins._vunit_obj._output_path, simulator + ) # pylint: disable=protected-access src_path = VHDL_PATH / "python" / "src" added_files = self._added_files() @@ -199,9 +202,10 @@ def test_application_build_failure_is_reported(self): simulator = self._simulator_with_flis("rivierapro", {"VHPI"}) builtins = self._builtins(simulator=simulator) builtins.add_vhdl_builtins() - with mock.patch("vunit.builtins.setup_vhpi_application", side_effect=RuntimeError("no compiler")), mock.patch( - "vunit.builtins.LOGGER" - ) as logger: + with ( + mock.patch("vunit.builtins.setup_vhpi_application", side_effect=RuntimeError("no compiler")), + mock.patch("vunit.builtins.LOGGER") as logger, + ): with self.assertRaises(SystemExit): builtins.add("python") logger.error.assert_called_once_with("%s", mock.ANY) @@ -227,9 +231,7 @@ def _check_bridge_files_added(self, simulator): # The bridge must be handed the VUnit object's own project, output # path, simulator class and run script path (whose directory the # runtime puts on sys.path, like python does for a run script). - setup_mock.assert_called_once_with( - self.vu._project, self.vu._output_path, simulator, self.vu._run_script_path - ) + setup_mock.assert_called_once_with(self.vu._project, self.vu._output_path, simulator, self.vu._run_script_path) src_path = VHDL_PATH / "python" / "src" added_files = self._added_files() @@ -250,9 +252,10 @@ def test_fli_uses_the_bridge_not_the_vhpi_application(self): simulator = self._simulator_with_flis("modelsim", {"FLI"}) builtins = self._builtins(simulator=simulator) builtins.add_vhdl_builtins() - with mock.patch("vunit.python_bridge.bridge.setup"), mock.patch( - "vunit.builtins.setup_vhpi_application" - ) as vhpi_mock: + with ( + mock.patch("vunit.python_bridge.bridge.setup"), + mock.patch("vunit.builtins.setup_vhpi_application") as vhpi_mock, + ): builtins.add("python") vhpi_mock.assert_not_called() @@ -323,30 +326,17 @@ def test_exports_match_the_native_library(self): declared = set(re.findall(r'VHPIDIRECT \S+ (\w+)"', text)) self.assertEqual(declared, set(EXPECTED_EXPORTS)) - def test_token_is_library_file_name_for_nvc(self): - bridge = self._setup(_FakeSimulator("nvc")) - text = self._ffi_text(bridge) - self.assertIn('"VHPIDIRECT libvunit_python_bridge.so vpy_begin"', text) - - def test_token_is_library_file_name_for_ghdl_mcode(self): - bridge = self._setup(_FakeSimulator("ghdl", backend="mcode")) - text = self._ffi_text(bridge) - self.assertIn('"VHPIDIRECT libvunit_python_bridge.so vpy_begin"', text) - - def test_token_is_library_file_name_for_ghdl_llvm_jit(self): - bridge = self._setup(_FakeSimulator("ghdl", backend="llvm-jit")) - text = self._ffi_text(bridge) - self.assertIn('"VHPIDIRECT libvunit_python_bridge.so vpy_begin"', text) - - def test_token_is_link_flag_for_ghdl_llvm(self): - bridge = self._setup(_FakeSimulator("ghdl", backend="llvm")) - text = self._ffi_text(bridge) - self.assertIn('"VHPIDIRECT -lvunit_python_bridge vpy_begin"', text) - - def test_token_is_link_flag_for_ghdl_gcc(self): - bridge = self._setup(_FakeSimulator("ghdl", backend="gcc")) - text = self._ffi_text(bridge) - self.assertIn('"VHPIDIRECT -lvunit_python_bridge vpy_begin"', text) + def test_token_is_the_file_name_or_the_link_flag_of_the_backend(self): + # Only the GHDL backends linking ahead of time get the linker flag + for simulator, token in ( + (_FakeSimulator("nvc"), "libvunit_python_bridge.so"), + (_FakeSimulator("ghdl", backend="mcode"), "libvunit_python_bridge.so"), + (_FakeSimulator("ghdl", backend="llvm-jit"), "libvunit_python_bridge.so"), + (_FakeSimulator("ghdl", backend="llvm"), "-lvunit_python_bridge"), + (_FakeSimulator("ghdl", backend="gcc"), "-lvunit_python_bridge"), + ): + with self.subTest(simulator=simulator.name, backend=simulator.determine_backend(None)): + self.assertIn(f'"VHPIDIRECT {token} vpy_begin"', self._ffi_text(self._setup(simulator))) def test_no_remaining_placeholder(self): for bridge in (self._setup(_FakeSimulator("nvc")), self._fli_setup()): @@ -600,6 +590,10 @@ def test_no_prebuilt_library_in_repository(self): self.assertEqual(matches, []) +def _config_keys(text): + return dict(line.split("=", 1) for line in text.splitlines()) + + class TestConfigFile(unittest.TestCase): """ _config_text / _write_if_changed @@ -608,7 +602,7 @@ class TestConfigFile(unittest.TestCase): def test_config_keys_linux(self): with mock.patch("sys.platform", "linux"): text = bridge_setup._config_text("/run/script/dir") # pylint: disable=protected-access - keys = dict(line.split("=", 1) for line in text.splitlines()) + keys = _config_keys(text) self.assertEqual(set(keys), {"executable", "prefix", "runtime", "run_script_dir"}) self.assertEqual(keys["executable"], sys.executable) self.assertEqual(keys["prefix"], sys.prefix) @@ -621,7 +615,7 @@ def test_config_keys_windows_include_python_dll(self): mock.patch("vunit.python_bridge.bridge.windows_python_dll", return_value=r"C:\python.dll"), ): text = bridge_setup._config_text("/run/script/dir") # pylint: disable=protected-access - keys = dict(line.split("=", 1) for line in text.splitlines()) + keys = _config_keys(text) self.assertEqual(keys["python_dll"], r"C:\python.dll") def test_write_if_changed_does_not_rewrite_identical_content(self): @@ -646,7 +640,7 @@ def test_run_script_dir_is_the_directory_of_the_run_script(self): with mock.patch("vunit.python_bridge.bridge.prepare_library", return_value=fake_library_file): bridge_setup.setup(_BridgeKey(), tempdir / "out", None, run_script) config = (fake_library_file.parent / bridge_setup.CONFIG_FILE_NAME).read_text(encoding="utf-8") - keys = dict(line.split("=", 1) for line in config.splitlines()) + keys = _config_keys(config) self.assertEqual(keys["run_script_dir"], str(tempdir.resolve())) def test_config_paths_with_spaces_and_unicode_written_as_utf8(self): @@ -675,16 +669,14 @@ class TestWindowsDllSelection(unittest.TestCase): def test_windows_dll_name_for_supported_versions(self): for minor in range(10, 15): - self.assertEqual( - native_library.windows_dll_name((3, minor)), - f"vunit_python_bridge-cp3{minor}-win_amd64.dll", - ) + with mock.patch("sys.version_info", (3, minor)): + self.assertEqual(native_library.windows_dll_name(), f"vunit_python_bridge-cp3{minor}-win_amd64.dll") def _prepare(self, tempdir, dll_bytes, version_info=(3, 12)): binary_path = tempdir / "bin" binary_path.mkdir(exist_ok=True) - name = native_library.windows_dll_name(version_info) - (binary_path / name).write_bytes(dll_bytes) + with mock.patch("sys.version_info", version_info): + (binary_path / native_library.windows_dll_name()).write_bytes(dll_bytes) root = tempdir / "root" with ( mock.patch("vunit.python_bridge.native_library.BINARY_PATH", binary_path), @@ -846,119 +838,69 @@ def setUp(self): os.chdir(self.scratch_dir) self.addCleanup(os.chdir, self.cwd) - def test_ghdl_get_command_only_adds_wl_L_for_linking_backends(self): + BRIDGE_LIBRARY = Path("/bridge/dir/libvunit_python_bridge.so") + + def _config(self): from tests.unit.test_test_bench import Entity # pylint: disable=import-outside-toplevel - from vunit.sim_if.ghdl import GHDLInterface # pylint: disable=import-outside-toplevel - from vunit.project import Project # pylint: disable=import-outside-toplevel from vunit.configuration import Configuration # pylint: disable=import-outside-toplevel - from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel design_unit = Entity("tb_entity", file_name=str(Path("tempdir") / "file.vhd")) design_unit.original_file_name = str(Path("tempdir") / "other_path" / "original_file.vhd") design_unit.generic_names = ["runner_cfg", "tb_path"] - config = Configuration("name", design_unit) + return Configuration("name", design_unit) + + def _prepare(self, simif, with_bridge): + """ + Give a simulator interface a project, with the bridge enabled or not. + """ + from vunit.project import Project # pylint: disable=import-outside-toplevel + from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel + + simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access + simif._project = Project() # pylint: disable=protected-access + simif._project.add_library("lib", str(self.scratch_dir)) # pylint: disable=protected-access + if with_bridge: + bridge = bridge_setup.PythonBridge(library_file=self.BRIDGE_LIBRARY, vhdl_files=[]) + bridge_setup._BRIDGES[simif._project] = bridge # pylint: disable=protected-access + self.addCleanup(bridge_setup._BRIDGES.pop, simif._project, None) # pylint: disable=protected-access + + def test_ghdl_get_command_only_adds_wl_L_for_linking_backends(self): + from vunit.sim_if.ghdl import GHDLInterface # pylint: disable=import-outside-toplevel for backend, expect_flag in (("llvm", True), ("gcc", True), ("mcode", False), ("llvm-jit", False)): with mock.patch.object(GHDLInterface, "determine_version", return_value=5.0): simif = GHDLInterface(prefix="prefix", output_path="", backend=backend) - simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access - simif._project = Project() # pylint: disable=protected-access - simif._project.add_library("lib", "lib_path") # pylint: disable=protected-access - - bridge = bridge_setup.PythonBridge( - library_file=Path("/bridge/dir/libvunit_python_bridge.so"), vhdl_files=[] + self._prepare(simif, with_bridge=True) + cmd = simif._get_command( # pylint: disable=protected-access + self._config(), str(Path("output_path") / "ghdl"), True, False, "tb_entity", None ) - bridge_setup._BRIDGES[simif._project] = bridge # pylint: disable=protected-access - try: - cmd = simif._get_command( # pylint: disable=protected-access - config, str(Path("output_path") / "ghdl"), True, False, "tb_entity", None - ) - finally: - bridge_setup._BRIDGES.pop(simif._project, None) # pylint: disable=protected-access - - flag = f"-Wl,-L{Path('/bridge/dir')!s}" + flag = f"-Wl,-L{self.BRIDGE_LIBRARY.parent!s}" if expect_flag: self.assertIn(flag, cmd, f"backend={backend}") else: self.assertNotIn(flag, cmd, f"backend={backend}") - def test_nvc_simulate_loads_bridge_after_dash_r(self): + def _nvc_simulate(self, with_bridge): + """ + The nvc command of a simulation. + """ from vunit.sim_if.nvc import NVCInterface # pylint: disable=import-outside-toplevel - from tests.unit.test_test_bench import Entity # pylint: disable=import-outside-toplevel - from vunit.project import Project # pylint: disable=import-outside-toplevel - from vunit.configuration import Configuration # pylint: disable=import-outside-toplevel - from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel - design_unit = Entity("tb_entity", file_name=str(Path("tempdir") / "file.vhd")) - design_unit.original_file_name = str(Path("tempdir") / "other_path" / "original_file.vhd") - design_unit.generic_names = ["runner_cfg", "tb_path"] - config = Configuration("name", design_unit) + with mock.patch.object(NVCInterface, "determine_version", return_value=(1, 99)): + simif = NVCInterface(output_path=str(self.scratch_dir), prefix="prefix", num_threads=1) + self._prepare(simif, with_bridge) + with mock.patch("vunit.sim_if.nvc.Process") as process: + simif.simulate(str(self.scratch_dir), "tb_entity", self._config(), elaborate_only=False) + return process.call_args.args[0] - with create_tempdir() as tempdir: - with mock.patch.object(NVCInterface, "determine_version", return_value=(1, 99)): - simif = NVCInterface(output_path=str(tempdir), prefix="prefix", num_threads=1) - simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access - simif._project = Project() # pylint: disable=protected-access - simif._project.add_library("lib", str(tempdir)) # pylint: disable=protected-access - - bridge = bridge_setup.PythonBridge( - library_file=Path("/bridge/dir/libvunit_python_bridge.so"), vhdl_files=[] - ) - bridge_setup._BRIDGES[simif._project] = bridge # pylint: disable=protected-access - - captured = {} - - class _FakeProcess: - def __init__(self, cmd, env=None): - captured["cmd"] = cmd - - def consume_output(self): - pass - - try: - with mock.patch("vunit.sim_if.nvc.Process", _FakeProcess): - simif.simulate(str(tempdir), "tb_entity", config, elaborate_only=False) - finally: - bridge_setup._BRIDGES.pop(simif._project, None) # pylint: disable=protected-access - - cmd = captured["cmd"] - self.assertIn("-r", cmd) - load = f"--load={Path('/bridge/dir/libvunit_python_bridge.so')!s}" - self.assertIn(load, cmd) - self.assertGreater(cmd.index(load), cmd.index("-r")) + def test_nvc_simulate_loads_bridge_after_dash_r(self): + cmd = self._nvc_simulate(with_bridge=True) + load = f"--load={self.BRIDGE_LIBRARY!s}" + self.assertIn(load, cmd) + self.assertGreater(cmd.index(load), cmd.index("-r")) def test_nvc_simulate_has_no_load_flag_without_bridge(self): - from vunit.sim_if.nvc import NVCInterface # pylint: disable=import-outside-toplevel - from tests.unit.test_test_bench import Entity # pylint: disable=import-outside-toplevel - from vunit.project import Project # pylint: disable=import-outside-toplevel - from vunit.configuration import Configuration # pylint: disable=import-outside-toplevel - from vunit.vhdl_standard import VHDL # pylint: disable=import-outside-toplevel - - design_unit = Entity("tb_entity", file_name=str(Path("tempdir") / "file.vhd")) - design_unit.original_file_name = str(Path("tempdir") / "other_path" / "original_file.vhd") - design_unit.generic_names = ["runner_cfg", "tb_path"] - config = Configuration("name", design_unit) - - with create_tempdir() as tempdir: - with mock.patch.object(NVCInterface, "determine_version", return_value=(1, 99)): - simif = NVCInterface(output_path=str(tempdir), prefix="prefix", num_threads=1) - simif._vhdl_standard = VHDL.standard("2008") # pylint: disable=protected-access - simif._project = Project() # pylint: disable=protected-access - simif._project.add_library("lib", str(tempdir)) # pylint: disable=protected-access - - captured = {} - - class _FakeProcess: - def __init__(self, cmd, env=None): - captured["cmd"] = cmd - - def consume_output(self): - pass - - with mock.patch("vunit.sim_if.nvc.Process", _FakeProcess): - simif.simulate(str(tempdir), "tb_entity", config, elaborate_only=False) - - self.assertFalse(any(flag.startswith("--load=") for flag in captured["cmd"])) + self.assertFalse(any(flag.startswith("--load=") for flag in self._nvc_simulate(with_bridge=False))) class TestForeignApplicationBuild(unittest.TestCase): @@ -968,10 +910,7 @@ class TestForeignApplicationBuild(unittest.TestCase): @staticmethod def _simulator(prefix): - simulator = mock.Mock() - simulator.name = "rivierapro" - simulator.find_prefix.return_value = str(prefix) - return simulator + return _FakeSimulator("rivierapro", prefix=str(prefix)) def test_builds_once_and_rebuilds_when_the_fingerprint_changes(self): with create_tempdir() as tempdir: @@ -1013,4 +952,3 @@ def test_failed_build_leaves_no_fingerprint(self): if __name__ == "__main__": unittest.main() - diff --git a/vunit/python_bridge/bridge.py b/vunit/python_bridge/bridge.py index 53afc56cc..36879d00e 100644 --- a/vunit/python_bridge/bridge.py +++ b/vunit/python_bridge/bridge.py @@ -13,7 +13,7 @@ import re import sys from pathlib import Path -from typing import Callable, List, Optional +from typing import List, NamedTuple, Optional from weakref import WeakKeyDictionary from .native_library import ( @@ -40,22 +40,19 @@ # {foreign:} placeholder of the bridge package template FOREIGN_PATTERN = re.compile(r"\{foreign:(\w+)\}") +# The subprogram declarations of the template, whose bodies are generated +SUBPROGRAM_PATTERN = re.compile(r"^ (impure function|procedure) (\w+)(\([^)]*\))?( return \w+)?;", re.MULTILINE) _BRIDGES: "WeakKeyDictionary[object, PythonBridge]" = WeakKeyDictionary() -class PythonBridge: +class PythonBridge(NamedTuple): """ - A prepared bridge: native library, its configuration and the generated VHDL. + A prepared bridge: the native library and the generated VHDL. """ - def __init__(self, library_file: Path, vhdl_files: List[Path]) -> None: - self.library_file = library_file - self.vhdl_files = vhdl_files - - @property - def directory(self) -> Path: - return self.library_file.parent + library_file: Path + vhdl_files: List[Path] def setup(project, output_path: str, simulator_class, run_script_path: Path) -> PythonBridge: @@ -81,15 +78,16 @@ def setup(project, output_path: str, simulator_class, run_script_path: Path) -> run_script_dir = str(Path(run_script_path).resolve().parent) _write_if_changed(library_file.parent / CONFIG_FILE_NAME, _config_text(run_script_dir)) - bridge_package = root / "vhdl" / "python_bridge_pkg.vhd" - _write_if_changed( - bridge_package, - _render_bridge_package( - _fli_foreign(library_file) - if is_fli - else _vhpidirect_foreign(_vhpidirect_token(simulator_name, simulator_class, library_file)) - ), + # The foreign attribute string of an entry point: the name of its wrapper in native/fli.c + # and the library, by absolute path since Questa accepts it, for the FLI; the VHPIDIRECT + # library token and the entry point itself for NVC and GHDL. + foreign = ( + f"fli_{{entry_point}} {library_file!s}" + if is_fli + else f"VHPIDIRECT {_vhpidirect_token(simulator_name, simulator_class, library_file)} {{entry_point}}" ) + bridge_package = root / "vhdl" / "python_bridge_pkg.vhd" + _write_if_changed(bridge_package, _render_bridge_package(foreign)) bridge = PythonBridge( library_file, @@ -107,34 +105,30 @@ def _vhpidirect_token(simulator_name, simulator_class, library_file: Path) -> st The library token of the VHPIDIRECT attributes: the file name the simulator dlopen()s, or the linker flag of the GHDL backends that link the design ahead of time. """ - if simulator_name == "ghdl" and _ghdl_backend(simulator_class) in GHDL_LINKING_BACKENDS: + if ( + simulator_name == "ghdl" + and simulator_class.determine_backend(simulator_class.find_prefix()) in GHDL_LINKING_BACKENDS + ): return "-lvunit_python_bridge" return library_file.name -def _vhpidirect_foreign(library_token: str) -> Callable[[str], str]: - """ - The VHPIDIRECT attribute string of an entry point, for NVC and GHDL. - """ - return lambda entry_point: f"VHPIDIRECT {library_token} {entry_point}" - - -def _fli_foreign(library_file: Path) -> Callable[[str], str]: +def _render_bridge_package(foreign: str) -> str: """ - The FLI attribute string of an entry point: the name of its wrapper in native/fli.c and the - library to load it from. Questa accepts the absolute path, so the library can stay in the - bridge cache directory instead of being copied next to the simulation. - """ - return lambda entry_point: f"fli_{entry_point} {library_file!s}" - - -def _render_bridge_package(foreign: Callable[[str], str]) -> str: - """ - The generated python_bridge_pkg.vhd: the template with every {foreign:} - placeholder replaced by the attribute string of the selected simulator. + The generated python_bridge_pkg.vhd: the declarations of the template with the foreign + attribute of every subprogram, and a body reporting a failure for each of them since the + bodies are replaced by the foreign implementations and never executed. """ template = BRIDGE_PACKAGE_TEMPLATE.read_text(encoding="utf-8") - return FOREIGN_PATTERN.sub(lambda match: foreign(match.group(1)), template) + declarations = FOREIGN_PATTERN.sub(lambda match: foreign.format(entry_point=match.group(1)), template) + stubs = [] + for kind, name, parameters, result in SUBPROGRAM_PATTERN.findall(declarations): + stubs.append(f" {kind} {name}{parameters}{result} is\n begin") + stubs.append(f' report "VUnit Python bridge: foreign subprogram {name} is not bound" severity failure;') + if result: + stubs.append(f" return {'0.0' if result.endswith('real') else '1'};") + stubs.append(" end;\n") + return declarations + "\npackage body python_bridge_pkg is\n" + "\n".join(stubs) + "end package body;\n" def get_bridge(project) -> Optional[PythonBridge]: @@ -173,13 +167,3 @@ def _write_if_changed(path: Path, text: str) -> None: tmp = path.with_name(path.name + f".{os.getpid()}.tmp") tmp.write_bytes(data) os.replace(tmp, path) - - -def _ghdl_backend(simulator_class) -> Optional[str]: - """ - Backend of the GHDL that will be used, None if not found. - """ - prefix = simulator_class.find_prefix() - if prefix is None: - return None - return simulator_class.determine_backend(prefix) diff --git a/vunit/python_bridge/foreign_application.py b/vunit/python_bridge/foreign_application.py index 6fb569521..ae1f1326d 100644 --- a/vunit/python_bridge/foreign_application.py +++ b/vunit/python_bridge/foreign_application.py @@ -15,9 +15,9 @@ from pathlib import Path import sys -import subprocess import hashlib +from .native_library import compile_library SRC_PATH = Path(__file__).parent.parent.resolve() / "vhdl" / "python" / "src" @@ -28,9 +28,17 @@ def setup_vhpi_application(output_path, simulator_class): already there was built from the same sources for the same Python and simulator. """ target = Path(output_path) / simulator_class.name / "libraries" / "python.dll" - _build_if_stale( - target, [SRC_PATH / "python_pkg_vhpi.c", SRC_PATH / "python_pkg.c"], simulator_class, _build_vhpi - ) + sources = [SRC_PATH / "python_pkg_vhpi.c", SRC_PATH / "python_pkg.c"] + simulator_prefix = Path(simulator_class.find_prefix()).resolve() + fingerprint = _fingerprint(sources, simulator_prefix) + fingerprint_file = target.with_suffix(target.suffix + ".fingerprint") + if target.exists() and fingerprint_file.exists() and fingerprint_file.read_text(encoding="utf-8") == fingerprint: + return + + target.parent.mkdir(parents=True, exist_ok=True) + fingerprint_file.unlink(missing_ok=True) + _build_vhpi(target, sources, simulator_prefix) + fingerprint_file.write_text(fingerprint, encoding="utf-8") def _fingerprint(sources, simulator_prefix): @@ -46,32 +54,6 @@ def _fingerprint(sources, simulator_prefix): return digest.hexdigest() -def _build_if_stale(target, sources, simulator_class, build): - """ - Build target with build(target, sources, simulator_prefix) unless it is up to date. - """ - simulator_prefix = Path(simulator_class.find_prefix()).resolve() - fingerprint = _fingerprint(sources, simulator_prefix) - fingerprint_file = target.with_suffix(target.suffix + ".fingerprint") - if target.exists() and fingerprint_file.exists() and fingerprint_file.read_text(encoding="utf-8") == fingerprint: - return - - target.parent.mkdir(parents=True, exist_ok=True) - if fingerprint_file.exists(): - fingerprint_file.unlink() - build(target, sources, simulator_prefix) - fingerprint_file.write_text(fingerprint, encoding="utf-8") - - -def _run(args, what): - """ - Run a compiler command, failing with its output. - """ - proc = subprocess.run(args, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise RuntimeError(f"Failed to {what}:\n{' '.join(str(arg) for arg in args)}\n{proc.stdout}{proc.stderr}") - - def _build_vhpi(target, sources, simulator_prefix): """ Compile the VHPI application with the ccomp compiler driver of Riviera-PRO/Active-HDL. @@ -83,4 +65,4 @@ def _build_vhpi(target, sources, simulator_prefix): args += ["-l", f"python{sys.version_info[0]}{sys.version_info[1]}", "-l", "python3", "-l", "_tkinter"] args += ["-I", f'"{python_include}"', "-I", f'"{SRC_PATH}"', "-L", f'"{python_libs}"'] args += [" ".join(f'"{path}"' for path in sources)] - _run(args, "compile the VHPI application") + compile_library(args, target, target) diff --git a/vunit/python_bridge/native/config.c b/vunit/python_bridge/native/config.c index 276956e07..9bfb83e36 100644 --- a/vunit/python_bridge/native/config.c +++ b/vunit/python_bridge/native/config.c @@ -12,6 +12,7 @@ #include "bridge.h" +#include #include #include #include @@ -133,20 +134,20 @@ static FILE *open_config_file(void) { /* The field of config for a key, NULL for unknown keys. */ static char **config_field(vpy_config_t *config, const char *key) { - if (strcmp(key, "executable") == 0) { - return &config->executable; - } - if (strcmp(key, "prefix") == 0) { - return &config->prefix; - } - if (strcmp(key, "runtime") == 0) { - return &config->runtime; - } - if (strcmp(key, "run_script_dir") == 0) { - return &config->run_script_dir; - } - if (strcmp(key, "python_dll") == 0) { - return &config->python_dll; + static const struct { + const char *key; + size_t offset; + } fields[] = {{"executable", offsetof(vpy_config_t, executable)}, + {"prefix", offsetof(vpy_config_t, prefix)}, + {"runtime", offsetof(vpy_config_t, runtime)}, + {"run_script_dir", offsetof(vpy_config_t, run_script_dir)}, + {"python_dll", offsetof(vpy_config_t, python_dll)}}; + size_t index; + + for (index = 0; index < sizeof(fields) / sizeof(fields[0]); index++) { + if (strcmp(key, fields[index].key) == 0) { + return (char **)((char *)config + fields[index].offset); + } } return NULL; } diff --git a/vunit/python_bridge/native/error.c b/vunit/python_bridge/native/error.c index b6e96a19b..9adf109d3 100644 --- a/vunit/python_bridge/native/error.c +++ b/vunit/python_bridge/native/error.c @@ -10,6 +10,7 @@ #include "bridge.h" +#include #include #include @@ -36,21 +37,10 @@ void vpy_set_error(const char *text) { vpy_set_error_bytes(text, strlen(text)); /* prefix may be the current error text, it is copied before being replaced. */ void vpy_set_error2(const char *prefix, const char *detail) { - size_t prefix_length = strlen(prefix); - size_t detail_length = detail == NULL ? 0 : strlen(detail); - char *text = (char *)malloc(prefix_length + detail_length + 1); + char text[4096]; + int length = snprintf(text, sizeof(text), "%s%s", prefix, detail == NULL ? "" : detail); - if (text == NULL) { - vpy_set_error(prefix); - return; - } - memcpy(text, prefix, prefix_length); - if (detail_length > 0) { - memcpy(text + prefix_length, detail, detail_length); - } - text[prefix_length + detail_length] = '\0'; - vpy_set_error_bytes(text, prefix_length + detail_length); - free(text); + vpy_set_error_bytes(text, length < 0 ? 0 : (length < (int)sizeof(text) ? (size_t)length : sizeof(text) - 1)); } int vpy_has_error(void) { return g_error != NULL; } diff --git a/vunit/python_bridge/native/interpreter.c b/vunit/python_bridge/native/interpreter.c index 73d0aaec1..c1b7390c3 100644 --- a/vunit/python_bridge/native/interpreter.c +++ b/vunit/python_bridge/native/interpreter.c @@ -71,17 +71,11 @@ static int load_python_library(void) { module = LoadLibraryExW(wpath, NULL, LOAD_WITH_ALTERED_SEARCH_PATH); free(wpath); if (module == NULL) { - unsigned long code = (unsigned long)GetLastError(); - size_t size = strlen(g_config.python_dll) + 80; - char *message = (char *)malloc(size); - - if (message == NULL) { - vpy_set_error("Failed to load the Python DLL"); - } else { - snprintf(message, size, "Failed to load the Python DLL %s (Windows error %lu)", g_config.python_dll, code); - vpy_set_error(message); - free(message); - } + char message[4096]; + + snprintf(message, sizeof(message), "Failed to load the Python DLL %s (Windows error %lu)", g_config.python_dll, + (unsigned long)GetLastError()); + vpy_set_error(message); return VPY_ERROR; } /* Intentionally never freed: the interpreter lives until process exit. */ diff --git a/vunit/python_bridge/native/operations.c b/vunit/python_bridge/native/operations.c index 6e5f09e38..8cd64f4f7 100644 --- a/vunit/python_bridge/native/operations.c +++ b/vunit/python_bridge/native/operations.c @@ -41,21 +41,12 @@ static void flush_c_streams(void) { } /* - * Start the interpreter. Eager and idempotent: later calls only re-run the - * runtime's setup, which does nothing but flush. Optional: every operation + * Start the interpreter. Eager and idempotent. Optional: every operation * starts the interpreter if it is not running, see vpy_begin. */ VPY_EXPORT int32_t vpy_setup(void) { - PyGILState_STATE gil; - int status; - flush_c_streams(); - if (vpy_initialize() != VPY_OK || vpy_enter(&gil) != VPY_OK) { - return VPY_ERROR; - } - status = vpy_finish_call(PyObject_CallMethod(vpy_runtime(), "setup", NULL)); - PyGILState_Release(gil); - return status; + return vpy_initialize(); } /* diff --git a/vunit/python_bridge/native_library.py b/vunit/python_bridge/native_library.py index 6538d257f..29ca3f4d3 100644 --- a/vunit/python_bridge/native_library.py +++ b/vunit/python_bridge/native_library.py @@ -21,7 +21,7 @@ import sys import sysconfig from pathlib import Path -from typing import List, Optional, Tuple +from typing import List, Optional PACKAGE_PATH = Path(__file__).parent.resolve() # C sources of the bridge library @@ -51,13 +51,6 @@ def check_python_build() -> None: raise PythonBridgeError(f"VHDL Python support requires CPython, not {sys.implementation.name}") -def library_file_name(fli: bool = False) -> str: - """ - File name of the bridge library built on POSIX platforms. - """ - return "libvunit_python_bridge_fli.so" if fli else "libvunit_python_bridge.so" - - def prepare_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path: """ Path of the bridge library for the running Python, built or selected under root. @@ -74,12 +67,11 @@ def prepare_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path return _prepare_posix_library(root, simulator_prefix) -def windows_dll_name(version_info: Optional[Tuple[int, ...]] = None) -> str: +def windows_dll_name() -> str: """ - File name of the prebuilt bridge DLL for a Python version. + File name of the prebuilt bridge DLL for the running Python. """ - major, minor = sys.version_info[:2] if version_info is None else version_info[:2] - return f"vunit_python_bridge-cp{major}{minor}-win_amd64.dll" + return f"vunit_python_bridge-cp{sys.version_info[0]}{sys.version_info[1]}-win_amd64.dll" def windows_python_dll() -> str: @@ -170,13 +162,12 @@ def _python_library() -> Path: ) -def _include_dirs() -> List[str]: +def _include_dirs(candidates: Optional[List[str]] = None) -> List[str]: """ - Include directories of the Python development headers. + Include directories of the Python development headers, by default the ones sysconfig names. """ - result = [] - for name in ("include", "platinclude"): - path = sysconfig.get_paths().get(name) + result: List[str] = [] + for path in candidates or [sysconfig.get_paths().get(name, "") for name in ("include", "platinclude")]: if path and path not in result: result.append(path) for path in result: @@ -207,14 +198,13 @@ def _compiler() -> List[str]: ) -def bridge_sources(native_path: Optional[Path] = None, fli: bool = False) -> List[Path]: +def bridge_sources(fli: bool = False) -> List[Path]: """ The C source files of the bridge library. fli adds the FLI front end. """ - path = NATIVE_PATH if native_path is None else native_path - sources = sorted(item for item in path.glob("*.c") if item.name != FLI_SOURCE_NAME) + sources = sorted(item for item in NATIVE_PATH.glob("*.c") if item.name != FLI_SOURCE_NAME) if fli: - sources.append(path / FLI_SOURCE_NAME) + sources.append(NATIVE_PATH / FLI_SOURCE_NAME) return sources @@ -276,7 +266,7 @@ def _prepare_posix_library(root: Path, simulator_prefix: Optional[Path] = None) if simulator_prefix is not None: include_dirs = include_dirs + [_fli_include_dir(simulator_prefix)] - name = library_file_name(fli) + name = "libvunit_python_bridge_fli.so" if fli else "libvunit_python_bridge.so" directory = _posix_cache_directory(root, python_library, include_dirs) library_file = directory / name if library_file.is_file(): @@ -295,10 +285,18 @@ def _prepare_posix_library(root: Path, simulator_prefix: Optional[Path] = None) + ([f"-Wl,-install_name,@rpath/{name}"] if sys.platform == "darwin" else [f"-Wl,-soname,{name}"]) + (["-ldl"] if sys.platform.startswith("linux") else []) ) + return compile_library(cmd, tmp, library_file) + + +def compile_library(cmd: List[str], tmp: Path, library_file: Path) -> Path: + """ + Run a compiler command building tmp and move the result to library_file, failing with the + compiler output. + """ try: proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) except OSError as exc: - raise PythonBridgeError(f"Failed to run the C compiler {compiler[0]!r}: {exc}") from exc + raise PythonBridgeError(f"Failed to run the C compiler {cmd[0]!r}: {exc}") from exc if proc.returncode != 0: tmp.unlink(missing_ok=True) raise PythonBridgeError( @@ -307,7 +305,8 @@ def _prepare_posix_library(root: Path, simulator_prefix: Optional[Path] = None) + "\n" + proc.stdout.decode(errors="replace") ) - os.replace(tmp, library_file) + if tmp != library_file: + os.replace(tmp, library_file) return library_file @@ -339,13 +338,8 @@ def _build_windows_fli_library(root: Path, simulator_prefix: Path) -> Path: include = _fli_include_dir(simulator_prefix) gcc = _questa_mingw_gcc(simulator_prefix) python_home = Path(sys.executable).parent.resolve() - python_include = python_home / "include" + python_include = _include_dirs([str(python_home / "include")])[0] python_libs = python_home / "libs" - if not (python_include / "Python.h").is_file(): - raise PythonBridgeError( - f"VHDL Python support needs the Python development headers (Python.h) of {sys.executable} " - f"to build the Python bridge library, but they were not found in {python_include!s}" - ) key_items = [_source_fingerprint(), sys.version, str(python_home), str(simulator_prefix), gcc] key = hashlib.sha256("\n".join(key_items).encode("utf-8")).hexdigest()[:16] @@ -358,23 +352,10 @@ def _build_windows_fli_library(root: Path, simulator_prefix: Path) -> Path: tmp = directory / f"vunit_python_bridge_fli.dll.{os.getpid()}.tmp" cmd = ( [gcc, "-shared", "-m64", "-O2", "-D__USE_MINGW_ANSI_STDIO=1", "-freg-struct-return"] - + [f"-I{include}", f"-I{python_include!s}"] + + [f"-I{include}", f"-I{python_include}"] + [str(path) for path in bridge_sources(fli=True)] + ["-o", str(tmp)] + [f"-L{python_libs!s}", f"-lpython{sys.version_info[0]}{sys.version_info[1]}"] + [f"-L{simulator_prefix!s}", "-lmtipli"] ) - try: - proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) - except OSError as exc: - raise PythonBridgeError(f"Failed to run the C compiler {gcc!r}: {exc}") from exc - if proc.returncode != 0: - tmp.unlink(missing_ok=True) - raise PythonBridgeError( - "Failed to build the Python bridge library:\n" - + " ".join(shlex.quote(item) for item in cmd) - + "\n" - + proc.stdout.decode(errors="replace") - ) - os.replace(tmp, library_file) - return library_file + return compile_library(cmd, tmp, library_file) diff --git a/vunit/python_bridge/runtime.py b/vunit/python_bridge/runtime.py index 3ca6b1db5..4966e1e61 100644 --- a/vunit/python_bridge/runtime.py +++ b/vunit/python_bridge/runtime.py @@ -24,6 +24,7 @@ import traceback from pathlib import Path import __main__ +import numpy # type: ignore[import-not-found] # Result kinds, must match python_ffi_pkg KIND_INTEGER = 0 @@ -74,10 +75,7 @@ def _type_name(value): def _is_bool(value): """True for Python and NumPy booleans.""" - if isinstance(value, bool): - return True - numpy = sys.modules.get("numpy") - return numpy is not None and isinstance(value, numpy.bool_) + return isinstance(value, (bool, numpy.bool_)) def _is_float(value): @@ -87,10 +85,7 @@ def _is_float(value): An int is not accepted, matching the PyFloat_Check of the other python_ffi_pkg implementations. """ - if isinstance(value, float): - return True - numpy = sys.modules.get("numpy") - return numpy is not None and isinstance(value, numpy.floating) + return isinstance(value, (float, numpy.floating)) def _is_integer(value): @@ -114,50 +109,6 @@ def _type_error(kind, value, expected): ) -class StagedValues: - """ - Values transferred from VHDL and kept under an id. - - VHDL transfers an integer_array_t once and then refers to it as - ``__vunit__.staged()`` in the Python expressions it evaluates. Staged - values live until the simulation ends, or until python_cleanup releases - them early, so that one VHDL constant can be used in several calls. Every - use gets its own copy of the NumPy array so that a function modifying it in - place does not affect the next use. - """ - - def __init__(self): - self._values = {} # id -> (value, bit_width, is_signed) - - def stage(self, value, bit_width, is_signed): - """ - Stage a value and return its id. - """ - staged_id = len(self._values) + 1 - self._values[staged_id] = (value, bit_width, bool(is_signed)) - return staged_id - - def get(self, staged_id): - """ - A copy of a staged value with its metadata, as (value, bit_width, is_signed). - """ - entry = self._values.get(staged_id) - if entry is None: - raise ValueError(f"There is no value staged under the id {staged_id!r}") - value, bit_width, is_signed = entry - numpy = sys.modules.get("numpy") - if numpy is not None and isinstance(value, numpy.ndarray): - # The user code may modify it in place, keep the staged value intact - value = value.copy() - return (value, bit_width, is_signed) - - def clear(self): - """ - Drop all staged values. - """ - self._values.clear() - - class BridgeHandle: """ The ``__vunit__`` object that VHDL-generated Python expressions use. @@ -208,7 +159,13 @@ def __init__(self, run_script_dir, prefix): # Metadata of the integer_array_t values handed to the current # operation, keyed by id() of the NumPy array created for them. self._array_meta = {} - self._staged = StagedValues() + # Values transferred from VHDL, id -> (value, bit_width, is_signed). + # VHDL transfers an integer_array_t once and then refers to it as + # __vunit__.staged() in the Python expressions it evaluates. Staged + # values live until the simulation ends, or until python_cleanup + # releases them early, so that one VHDL constant can be used in + # several calls. + self._staged = {} self._check_environment(prefix) @@ -263,15 +220,6 @@ def format_exception(self, exc): # Lifecycle # ------------------------------------------------------------------ - def setup(self): - """ - Prepare for the operations to come. Idempotent: the interpreter and the - namespaces are created once and python_setup may be called any number - of times. Calling it at all is optional since every operation starts - the interpreter if it is not running. - """ - self._flush() - def cleanup(self): """ Release what the simulation acquired: the last result and the staged @@ -417,12 +365,15 @@ def evaluate(self, text, kind, width): def staged(self, staged_id): """ The value staged under an id, used from Python as __vunit__.staged(id). + Every use gets its own copy so that a function modifying it in place + does not affect the next use. """ - value, bit_width, is_signed = self._staged.get(staged_id) - numpy = sys.modules.get("numpy") - if numpy is not None and isinstance(value, numpy.ndarray): - # An expression returning this very array keeps its word size - self._array_meta[id(value)] = (value, bit_width, is_signed) + if staged_id not in self._staged: + raise ValueError(f"There is no value staged under the id {staged_id!r}") + value, bit_width, is_signed = self._staged[staged_id] + value = value.copy() + # An expression returning this very array keeps its word size + self._array_meta[id(value)] = (value, bit_width, is_signed) return value def stage_value(self, value): @@ -434,21 +385,9 @@ def stage_value(self, value): bit_width, is_signed = meta[1], meta[2] else: bit_width, is_signed = 32, True - return self._staged.stage(value, bit_width, is_signed) - - @staticmethod - def _numpy(): - """ - Import NumPy, which is only needed when integer_array_t values are exchanged. - """ - try: - import numpy # type: ignore[import-not-found] # pylint: disable=import-outside-toplevel - except ImportError as exc: - raise ImportError( - "integer_array_t values are exchanged as NumPy arrays but NumPy could not be imported " - f"in the Python environment used by VUnit ({sys.executable}): {exc}" - ) from exc - return numpy + staged_id = len(self._staged) + 1 + self._staged[staged_id] = (value, bit_width, bool(is_signed)) + return staged_id @staticmethod def _shape(length, width, height, depth): @@ -469,7 +408,6 @@ def make_array( Create the NumPy array for an integer_array_t value. The bridge fills the storage (native int32) after this call returns. """ - numpy = self._numpy() array = numpy.frombuffer(storage, dtype=numpy.int32).reshape(self._shape(length, width, height, depth)) self._array_meta[id(array)] = (array, bit_width, bool(is_signed)) return array @@ -492,23 +430,12 @@ def convert_result(self, kind, width): if value is _NO_VALUE: raise RuntimeError("Internal error: no Python result available") - converters = { - KIND_INTEGER: self._integer_result, - KIND_REAL: self._real_result, - KIND_BOOLEAN: self._boolean_result, - KIND_STRING: self._string_result, - KIND_STD_ULOGIC: self._std_ulogic_result, - KIND_STD_ULOGIC_VECTOR: self._std_ulogic_vector_result, - KIND_SIGNED: self._bits_result, - KIND_UNSIGNED: self._bits_result, - KIND_INTEGER_VECTOR: self._integer_vector_result, - KIND_REAL_VECTOR: self._real_vector_result, - } if kind == KIND_INTEGER_ARRAY: return self._array_result(value, array_meta) - if kind not in converters: + if kind not in VHDL_TYPE_NAMES: raise RuntimeError(f"Internal error: unknown result kind {kind}") - return converters[kind](kind, value, width) + converter = "bits" if kind in (KIND_SIGNED, KIND_UNSIGNED) else VHDL_TYPE_NAMES[kind] + return getattr(self, f"_{converter}_result")(kind, value, width) @staticmethod def _integer_result(kind, value, _width): @@ -664,9 +591,8 @@ def _array_result(self, value, array_meta): """ Convert a NumPy array (or nested int sequence) to integer_array_t data. """ - numpy = self._numpy() original = value - value = self._integer_ndarray(numpy, value) + value = self._integer_ndarray(value) bit_width, is_signed = self._word_size(original, value, array_meta) low, high = (-(1 << (bit_width - 1)), (1 << (bit_width - 1)) - 1) if is_signed else (0, (1 << bit_width) - 1) @@ -686,7 +612,7 @@ def _array_result(self, value, array_meta): return (0, 0.0, data, (value.size, width, height, depth, bit_width, int(is_signed))) @staticmethod - def _integer_ndarray(numpy, value): + def _integer_ndarray(value): """ The result as an integer NumPy array with 1 to 3 dimensions. """ diff --git a/vunit/python_bridge/simulator_hooks.py b/vunit/python_bridge/simulator_hooks.py index eadda950f..65ca9ebee 100644 --- a/vunit/python_bridge/simulator_hooks.py +++ b/vunit/python_bridge/simulator_hooks.py @@ -38,7 +38,7 @@ def ghdl_elab_flags(project, backend) -> List[str]: bridge = get_bridge(project) if bridge is None or backend not in GHDL_LINKING_BACKENDS: return [] - return [f"-Wl,-L{bridge.directory!s}"] + return [f"-Wl,-L{bridge.library_file.parent!s}"] def ghdl_run_env(project, env: Dict[str, str]) -> Dict[str, str]: @@ -50,7 +50,7 @@ def ghdl_run_env(project, env: Dict[str, str]) -> Dict[str, str]: return env variable = {"win32": "PATH", "darwin": "DYLD_LIBRARY_PATH"}.get(sys.platform, "LD_LIBRARY_PATH") env = dict(env) - env[variable] = os.pathsep.join(item for item in (str(bridge.directory), env.get(variable, "")) if item) + env[variable] = os.pathsep.join(item for item in (str(bridge.library_file.parent), env.get(variable, "")) if item) return env diff --git a/vunit/vhdl/python/src/python_bridge_pkg.vhd.in b/vunit/vhdl/python/src/python_bridge_pkg.vhd.in index 46bb455fe..67e43ade4 100644 --- a/vunit/vhdl/python/src/python_bridge_pkg.vhd.in +++ b/vunit/vhdl/python/src/python_bridge_pkg.vhd.in @@ -10,8 +10,10 @@ -- placeholder with the foreign attribute string of the selected simulator -- -- "VHPIDIRECT " for NVC and GHDL, "fli_ -- " for the FLI of Questa/ModelSim -- and compiles the result into --- vunit_lib when Python support is enabled with add_python(). The public API --- is python_pkg/python_ffi_pkg, which is implemented on top of this package. +-- vunit_lib when Python support is enabled with add_python(), together with +-- a generated package body whose subprograms are never executed. The public +-- API is python_pkg/python_ffi_pkg, which is implemented on top of this +-- package. -- -- Only scalars and constrained arrays cross the foreign interface since their -- ABI is identical for NVC and GHDL on all platforms and is translated to the @@ -88,109 +90,3 @@ package python_bridge_pkg is attribute foreign of vpy_error_read : procedure is "{foreign:vpy_error_read}"; end package; -package body python_bridge_pkg is - -- The bodies are replaced by the foreign implementations and never executed. - impure function vpy_setup return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_setup is not bound" severity failure; - return 1; - end; - - impure function vpy_cleanup return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_cleanup is not bound" severity failure; - return 1; - end; - - impure function vpy_buffer_clear return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_buffer_clear is not bound" severity failure; - return 1; - end; - - impure function vpy_buffer_append(chunk : string_chunk_t; length : integer) return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_buffer_append is not bound" severity failure; - return 1; - end; - - impure function vpy_begin return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_begin is not bound" severity failure; - return 1; - end; - - impure function vpy_execute(is_file : integer) return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_execute is not bound" severity failure; - return 1; - end; - - impure function vpy_eval(kind, width : integer) return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_eval is not bound" severity failure; - return 1; - end; - - impure function vpy_push_array(length, width, height, depth, bit_width, is_signed : integer) return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_push_array is not bound" severity failure; - return 1; - end; - - impure function vpy_array_write(chunk : integer_chunk_t; length : integer) return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_array_write is not bound" severity failure; - return 1; - end; - - impure function vpy_stage return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_stage is not bound" severity failure; - return -1; - end; - - impure function vpy_result_integer return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_result_integer is not bound" severity failure; - return 1; - end; - - impure function vpy_result_real return real is - begin - report "VUnit Python bridge: foreign subprogram vpy_result_real is not bound" severity failure; - return 0.0; - end; - - impure function vpy_result_meta(idx : integer) return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_result_meta is not bound" severity failure; - return 1; - end; - - impure function vpy_error_length return integer is - begin - report "VUnit Python bridge: foreign subprogram vpy_error_length is not bound" severity failure; - return 1; - end; - - procedure vpy_result_read_string(chunk : out string_chunk_t; offset, length : integer) is - begin - report "VUnit Python bridge: foreign subprogram vpy_result_read_string is not bound" severity failure; - end; - - procedure vpy_result_read_integers(chunk : out integer_chunk_t; offset, length : integer) is - begin - report "VUnit Python bridge: foreign subprogram vpy_result_read_integers is not bound" severity failure; - end; - - procedure vpy_result_read_reals(chunk : out real_chunk_t; offset, length : integer) is - begin - report "VUnit Python bridge: foreign subprogram vpy_result_read_reals is not bound" severity failure; - end; - - procedure vpy_error_read(chunk : out string_chunk_t; offset, length : integer) is - begin - report "VUnit Python bridge: foreign subprogram vpy_error_read is not bound" severity failure; - end; -end package body; From e56c08d58a836e03bec2af30978c4e3d228dcaac Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 09:31:31 +0200 Subject: [PATCH 20/28] Name the news fragments after their pull request contributing.rst asks for news fragments named ..rst. The feature of this pull request is 1220.feature.rst. The fragments it shares with #1221, for run_script_path and the check_result_t accessors, get the names and contents they have there, 1221.feature.1.rst and 1221.feature.2.rst, such that the pull requests do not conflict. Co-Authored-By: Claude Opus 5 (1M context) --- docs/news.d/{+python_bridge.feature.rst => 1220.feature.rst} | 0 .../{+check_result_getters.feature.rst => 1221.feature.1.rst} | 0 docs/news.d/{+run_script_path.feature.rst => 1221.feature.2.rst} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename docs/news.d/{+python_bridge.feature.rst => 1220.feature.rst} (100%) rename docs/news.d/{+check_result_getters.feature.rst => 1221.feature.1.rst} (100%) rename docs/news.d/{+run_script_path.feature.rst => 1221.feature.2.rst} (100%) diff --git a/docs/news.d/+python_bridge.feature.rst b/docs/news.d/1220.feature.rst similarity index 100% rename from docs/news.d/+python_bridge.feature.rst rename to docs/news.d/1220.feature.rst diff --git a/docs/news.d/+check_result_getters.feature.rst b/docs/news.d/1221.feature.1.rst similarity index 100% rename from docs/news.d/+check_result_getters.feature.rst rename to docs/news.d/1221.feature.1.rst diff --git a/docs/news.d/+run_script_path.feature.rst b/docs/news.d/1221.feature.2.rst similarity index 100% rename from docs/news.d/+run_script_path.feature.rst rename to docs/news.d/1221.feature.2.rst From c73f4f662d4fa0e693d6de600df562386eeb8ce1 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 09:31:31 +0200 Subject: [PATCH 21/28] Take the run script from the file Python was started with The run script was the file of the first call stack frame outside the vunit package. When the VUnit object is created in a helper module imported by the run script, that frame is the helper module, and without a script file, with python -c or in an interactive session, the current directory was returned as if it were the run script. The Python bridge then put the wrong directory on sys.path and import_run_script imported the wrong file. The run script is now the file of the __main__ module, or None when Python was not started with a script file. In that case runner_cfg has no run script path, the bridge puts the current directory first on sys.path as python -c does, and import_run_script fails with a message saying that VUnit was not started from a run script file. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/test_python_bridge.py | 22 +++++++++++++------ vunit/python_bridge/bridge.py | 8 +++---- vunit/test/suites.py | 3 ++- vunit/ui/__init__.py | 26 ++++++----------------- vunit/vhdl/python/src/python_pkg.vhd | 4 ++++ vunit/vhdl/python/tools/python_pkg.vhd.in | 4 ++++ 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py index 8bbfd7782..da9bb3239 100644 --- a/tests/unit/test_python_bridge.py +++ b/tests/unit/test_python_bridge.py @@ -278,16 +278,26 @@ def listing(): class TestFindRunScriptPath(unittest.TestCase): """ - vunit.ui._find_run_script_path(): the file of the first call-stack frame - outside the vunit package, used as the VUnit object's ``_run_script_path`` - (fed to the Python bridge and, through run_script_path(runner_cfg), to - import_run_script). + The run script is the file Python was started with, not the caller of VUnit """ - def test_returns_the_file_of_the_caller_outside_vunit(self): + def test_returns_the_file_python_was_started_with(self): from vunit.ui import _find_run_script_path # pylint: disable=import-outside-toplevel - self.assertEqual(_find_run_script_path(), Path(__file__).resolve()) + with create_tempdir() as tempdir: + script = Path(tempdir) / "run.py" + script.write_text("") + with mock.patch.dict(sys.modules, {"__main__": mock.Mock(__file__=str(script))}): + self.assertEqual(_find_run_script_path(), script.resolve()) + + def test_returns_none_without_a_script_file(self): + from vunit.ui import _find_run_script_path # pylint: disable=import-outside-toplevel + + # python -c and interactive sessions: __main__ has no file or a pseudo file name + with mock.patch.dict(sys.modules, {"__main__": mock.Mock(spec=[])}): + self.assertIsNone(_find_run_script_path()) + with mock.patch.dict(sys.modules, {"__main__": mock.Mock(__file__="")}): + self.assertIsNone(_find_run_script_path()) class TestBridgePackageSubstitution(unittest.TestCase): diff --git a/vunit/python_bridge/bridge.py b/vunit/python_bridge/bridge.py index 36879d00e..f13cb89ca 100644 --- a/vunit/python_bridge/bridge.py +++ b/vunit/python_bridge/bridge.py @@ -55,12 +55,12 @@ class PythonBridge(NamedTuple): vhdl_files: List[Path] -def setup(project, output_path: str, simulator_class, run_script_path: Path) -> PythonBridge: +def setup(project, output_path: str, simulator_class, run_script_path: Optional[Path]) -> PythonBridge: """ Prepare the Python bridge for a project. Called by add_python(). - :param run_script_path: The run script. Its directory is put on sys.path - by the runtime, like when it is started by python. + :param run_script_path: The run script or None. The runtime puts its directory, or the current + directory without a run script, first on sys.path, like python does. :returns: The bridge. Its vhdl_files are to be added to vunit_lib. """ simulator_name = None if simulator_class is None else simulator_class.name @@ -75,7 +75,7 @@ def setup(project, output_path: str, simulator_class, run_script_path: Path) -> is_fli = simulator_name in FLI_SIMULATORS root = Path(output_path) / "python_bridge" library_file = prepare_library(root, Path(simulator_class.find_prefix()) if is_fli else None) - run_script_dir = str(Path(run_script_path).resolve().parent) + run_script_dir = str(Path.cwd() if run_script_path is None else Path(run_script_path).resolve().parent) _write_if_changed(library_file.parent / CONFIG_FILE_NAME, _config_text(run_script_dir)) # The foreign attribute string of an entry point: the name of its wrapper in native/fli.c diff --git a/vunit/test/suites.py b/vunit/test/suites.py index 3d0c17472..928d62273 100644 --- a/vunit/test/suites.py +++ b/vunit/test/suites.py @@ -275,9 +275,10 @@ def _simulate(self, output_path, run_script_path): "output path": output_path.replace("\\", "/") + "/", "active python runner": True, "tb path": config.tb_path.replace("\\", "/") + "/", - "run script path": str(run_script_path).replace("\\", "/"), "seed": seed, } + if run_script_path is not None: + runner_cfg["run script path"] = str(run_script_path).replace("\\", "/") # @TODO Warn if runner cfg already set? config.generics["runner_cfg"] = encode_dict(runner_cfg) diff --git a/vunit/ui/__init__.py b/vunit/ui/__init__.py index cb33ec5b1..bcaeb9a96 100644 --- a/vunit/ui/__init__.py +++ b/vunit/ui/__init__.py @@ -12,7 +12,6 @@ import csv import sys -import inspect import traceback import logging import json @@ -50,26 +49,15 @@ from .results import Results -def _find_run_script_path() -> Path: +def _find_run_script_path() -> Optional[Path]: """ - Return the path of the run script: the file of the first call-stack frame - whose file is not inside the vunit package directory. + Return the path of the run script, the file Python was started with, or None if Python was + not started with a script file, for example with python -c or in an interactive session. """ - vunit_package_dir = Path(__file__).resolve().parent.parent - for frame_info in inspect.stack(0): - candidate = Path(frame_info.filename) - if not candidate.is_file(): - continue - candidate = candidate.resolve() - try: - candidate.relative_to(vunit_package_dir) - except ValueError: - return candidate - - if Path(sys.argv[0]).is_file(): - return Path(sys.argv[0]).resolve() - - return Path.cwd() + main_file = getattr(sys.modules.get("__main__"), "__file__", None) + if main_file is None or not Path(main_file).is_file(): + return None + return Path(main_file).resolve() class VUnit(object): # pylint: disable=too-many-instance-attributes, too-many-public-methods diff --git a/vunit/vhdl/python/src/python_pkg.vhd b/vunit/vhdl/python/src/python_pkg.vhd index 1bac31a8b..c302859d4 100644 --- a/vunit/vhdl/python/src/python_pkg.vhd +++ b/vunit/vhdl/python/src/python_pkg.vhd @@ -308,6 +308,10 @@ package body python_pkg is variable path_items : lines_t; variable script_name : line; begin + if script_path = "" then + exec("raise RuntimeError('import_run_script: VUnit was not started from a run script file')", session); + return; + end if; p_check_run_script_guard(script_path, session); if module_name = "" then -- Extract the last item in the full path diff --git a/vunit/vhdl/python/tools/python_pkg.vhd.in b/vunit/vhdl/python/tools/python_pkg.vhd.in index 3c241b0e9..5a4c551e0 100644 --- a/vunit/vhdl/python/tools/python_pkg.vhd.in +++ b/vunit/vhdl/python/tools/python_pkg.vhd.in @@ -210,6 +210,10 @@ package body python_pkg is variable path_items : lines_t; variable script_name : line; begin + if script_path = "" then + exec("raise RuntimeError('import_run_script: VUnit was not started from a run script file')", session); + return; + end if; p_check_run_script_guard(script_path, session); if module_name = "" then -- Extract the last item in the full path From 50a9d668dc8d1a0915fd9a42d5a14aab9783b381 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 09:31:31 +0200 Subject: [PATCH 22/28] Raise instead of exiting when add_python() cannot set up the package A failure to build the VHPI application or the Python bridge was logged and followed by sys.exit(1), ending the process of whatever called add_python(). The error is now raised with the same message, like the other errors of add_python() and of builtins.py. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/test_python_bridge.py | 8 ++------ vunit/builtins.py | 30 ++++++++++-------------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py index da9bb3239..85676d5d4 100644 --- a/tests/unit/test_python_bridge.py +++ b/tests/unit/test_python_bridge.py @@ -202,13 +202,9 @@ def test_application_build_failure_is_reported(self): simulator = self._simulator_with_flis("rivierapro", {"VHPI"}) builtins = self._builtins(simulator=simulator) builtins.add_vhdl_builtins() - with ( - mock.patch("vunit.builtins.setup_vhpi_application", side_effect=RuntimeError("no compiler")), - mock.patch("vunit.builtins.LOGGER") as logger, - ): - with self.assertRaises(SystemExit): + with mock.patch("vunit.builtins.setup_vhpi_application", side_effect=RuntimeError("no compiler")): + with self.assertRaisesRegex(RuntimeError, "no compiler"): builtins.add("python") - logger.error.assert_called_once_with("%s", mock.ANY) def _check_bridge_files_added(self, simulator): """ diff --git a/vunit/builtins.py b/vunit/builtins.py index e8f58faf2..8a5525112 100755 --- a/vunit/builtins.py +++ b/vunit/builtins.py @@ -16,7 +16,6 @@ import importlib.util import re import operator -import sys from dataclasses import dataclass from typing import TypeVar, Any, Tuple @@ -527,14 +526,10 @@ def _setup_python_application(self, setup): """ Build the VHPI application of the Python package under the output path when needed. """ - try: - setup( - self._vunit_obj._output_path, # pylint: disable=protected-access - self._simulator_class, - ) - except RuntimeError as exc: - LOGGER.error("%s", exc) - sys.exit(1) + setup( + self._vunit_obj._output_path, # pylint: disable=protected-access + self._simulator_class, + ) def _add_python_bridge(self): """ @@ -543,18 +538,13 @@ def _add_python_bridge(self): """ # pylint: disable=import-outside-toplevel from vunit.python_bridge.bridge import setup - from vunit.python_bridge.native_library import PythonBridgeError - try: - bridge = setup( - self._vunit_obj._project, # pylint: disable=protected-access - self._vunit_obj._output_path, # pylint: disable=protected-access - self._simulator_class, - self._vunit_obj._run_script_path, # pylint: disable=protected-access - ) - except PythonBridgeError as exc: - LOGGER.error("%s", exc) - sys.exit(1) + bridge = setup( + self._vunit_obj._project, # pylint: disable=protected-access + self._vunit_obj._output_path, # pylint: disable=protected-access + self._simulator_class, + self._vunit_obj._run_script_path, # pylint: disable=protected-access + ) for file_name in bridge.vhdl_files: self._vunit_lib.add_source_file(file_name) From 9a7db2dbe6090d20777ffd1745eb88ef68ee5eaa Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 22:53:30 +0200 Subject: [PATCH 23/28] Remove the input stimuli files of the embedded Python example The globbing test now globs the Python files of the example, and the GUI browsing test only loads a file when one is selected in debug mode. Co-Authored-By: Claude Opus 5 (1M context) --- .../embedded_python/data/default/input_stimuli.csv | 1 - .../embedded_python/data/extras/input_stimuli2.csv | 1 - examples/vhdl/embedded_python/tb_example.vhd | 14 +++++--------- 3 files changed, 5 insertions(+), 11 deletions(-) delete mode 100644 examples/vhdl/embedded_python/data/default/input_stimuli.csv delete mode 100644 examples/vhdl/embedded_python/data/extras/input_stimuli2.csv diff --git a/examples/vhdl/embedded_python/data/default/input_stimuli.csv b/examples/vhdl/embedded_python/data/default/input_stimuli.csv deleted file mode 100644 index 2739d724d..000000000 --- a/examples/vhdl/embedded_python/data/default/input_stimuli.csv +++ /dev/null @@ -1 +0,0 @@ -1, 2, 3 diff --git a/examples/vhdl/embedded_python/data/extras/input_stimuli2.csv b/examples/vhdl/embedded_python/data/extras/input_stimuli2.csv deleted file mode 100644 index 1ddc48e13..000000000 --- a/examples/vhdl/embedded_python/data/extras/input_stimuli2.csv +++ /dev/null @@ -1 +0,0 @@ -10, 20, 30 diff --git a/examples/vhdl/embedded_python/tb_example.vhd b/examples/vhdl/embedded_python/tb_example.vhd index e6a1bf071..fd41b8ca5 100644 --- a/examples/vhdl/embedded_python/tb_example.vhd +++ b/examples/vhdl/embedded_python/tb_example.vhd @@ -510,17 +510,15 @@ begin ); elsif run("Test globbing for files") then - -- glob finds all files matching a pattern, in this case all CSV in the directory tree rooted - -- in the testbench directory + -- glob finds all files matching a pattern, in this case all Python files in the directory tree + -- rooted in the testbench directory exec("from glob import glob"); - exec("stimuli_files = glob('" & join(tb_path(runner_cfg), "**", "*.csv") & "', recursive=True)"); + exec("python_files = glob('" & join(tb_path(runner_cfg), "**", "*.py") & "', recursive=True)"); - for idx in 0 to eval("len(stimuli_files)") - 1 loop + for idx in 0 to eval("len(python_files)") - 1 loop -- Be aware that you may receive backslashes that should be replaced with forward slashes to be -- VHDL compatible - input_stimuli := load_csv(replace(eval("stimuli_files[" & to_string(idx) & "]"), "\", "/")); - - -- Test with stimuli file... + info(replace(eval("python_files[" & to_string(idx) & "]"), "\", "/")); end loop; ------------------------------------------------------------------------------------- @@ -534,8 +532,6 @@ begin if debug_mode then exec("import PySimpleGUI as psg"); -- Install PySimpleGUI with pip install pysimplegui input_stimuli := load_csv(replace(eval("psg.popup_get_file('Select input stimuli file')"), "\", "/")); - else - input_stimuli := load_csv(join(tb_path(runner_cfg), "data", "default", "input_stimuli.csv")); end if; elsif run("Test querying for randomization seed") then From 6c70078636848f5d5f76fe29d65f10a7ea33b23e Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 23:02:29 +0200 Subject: [PATCH 24/28] Move the C sources of the VHPI application next to the bridge The Riviera-PRO/Active-HDL (VHPI) application is now in vunit/python_bridge/native/vhpi, so all C code of the Python bridge is under one directory. The first comment line of each file described a dictionary package and now says what the file is. Co-Authored-By: Claude Opus 5 (1M context) --- docs/python_bridge/user_guide.rst | 4 ++-- vunit/python_bridge/foreign_application.py | 2 +- .../python/src => python_bridge/native/vhpi}/python_pkg.c | 0 .../python/src => python_bridge/native/vhpi}/python_pkg.h | 0 .../src => python_bridge/native/vhpi}/python_pkg_vhpi.c | 0 5 files changed, 3 insertions(+), 3 deletions(-) rename vunit/{vhdl/python/src => python_bridge/native/vhpi}/python_pkg.c (100%) rename vunit/{vhdl/python/src => python_bridge/native/vhpi}/python_pkg.h (100%) rename vunit/{vhdl/python/src => python_bridge/native/vhpi}/python_pkg_vhpi.c (100%) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 312519730..151eba241 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -607,8 +607,8 @@ Other simulators ----------------- Riviera-PRO/Active-HDL (VHPI) implement ``python_ffi_pkg`` with a VHPI -application, built from the C sources in :vunit_file:`vunit/vhdl/python/src -` with their ``ccomp`` driver. :meth:`add_python() +application, built from the C sources in :vunit_file:`vunit/python_bridge/native/vhpi +` with their ``ccomp`` driver. :meth:`add_python() ` builds the application under the output path (``//libraries``) the first time it is called and rebuilds it when the sources, the Python running VUnit or the simulator diff --git a/vunit/python_bridge/foreign_application.py b/vunit/python_bridge/foreign_application.py index ae1f1326d..c22fc3327 100644 --- a/vunit/python_bridge/foreign_application.py +++ b/vunit/python_bridge/foreign_application.py @@ -19,7 +19,7 @@ from .native_library import compile_library -SRC_PATH = Path(__file__).parent.parent.resolve() / "vhdl" / "python" / "src" +SRC_PATH = Path(__file__).parent.resolve() / "native" / "vhpi" def setup_vhpi_application(output_path, simulator_class): diff --git a/vunit/vhdl/python/src/python_pkg.c b/vunit/python_bridge/native/vhpi/python_pkg.c similarity index 100% rename from vunit/vhdl/python/src/python_pkg.c rename to vunit/python_bridge/native/vhpi/python_pkg.c diff --git a/vunit/vhdl/python/src/python_pkg.h b/vunit/python_bridge/native/vhpi/python_pkg.h similarity index 100% rename from vunit/vhdl/python/src/python_pkg.h rename to vunit/python_bridge/native/vhpi/python_pkg.h diff --git a/vunit/vhdl/python/src/python_pkg_vhpi.c b/vunit/python_bridge/native/vhpi/python_pkg_vhpi.c similarity index 100% rename from vunit/vhdl/python/src/python_pkg_vhpi.c rename to vunit/python_bridge/native/vhpi/python_pkg_vhpi.c From ac6350bfde90b8a09f1399ce7ad4bda431182a87 Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Tue, 15 Sep 2026 23:02:46 +0200 Subject: [PATCH 25/28] Describe the C sources of the VHPI application in their first comment line The first line of each file described a dictionary package. Co-Authored-By: Claude Opus 5 (1M context) --- vunit/python_bridge/native/vhpi/python_pkg.c | 2 +- vunit/python_bridge/native/vhpi/python_pkg.h | 2 +- vunit/python_bridge/native/vhpi/python_pkg_vhpi.c | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vunit/python_bridge/native/vhpi/python_pkg.c b/vunit/python_bridge/native/vhpi/python_pkg.c index 85faa6794..543a4e800 100644 --- a/vunit/python_bridge/native/vhpi/python_pkg.c +++ b/vunit/python_bridge/native/vhpi/python_pkg.c @@ -1,4 +1,4 @@ -// This package provides a dictionary types and operations +// Riviera-PRO/Active-HDL (VHPI) implementation of python_pkg: Python error handling // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, diff --git a/vunit/python_bridge/native/vhpi/python_pkg.h b/vunit/python_bridge/native/vhpi/python_pkg.h index 950a8d555..3c48b5d84 100644 --- a/vunit/python_bridge/native/vhpi/python_pkg.h +++ b/vunit/python_bridge/native/vhpi/python_pkg.h @@ -1,4 +1,4 @@ -// This package provides a dictionary types and operations +// Riviera-PRO/Active-HDL (VHPI) implementation of python_pkg: shared declarations // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, diff --git a/vunit/python_bridge/native/vhpi/python_pkg_vhpi.c b/vunit/python_bridge/native/vhpi/python_pkg_vhpi.c index e4eac555a..fe6d226fb 100644 --- a/vunit/python_bridge/native/vhpi/python_pkg_vhpi.c +++ b/vunit/python_bridge/native/vhpi/python_pkg_vhpi.c @@ -1,4 +1,4 @@ -// This package provides a dictionary types and operations +// Riviera-PRO/Active-HDL (VHPI) implementation of python_pkg: the foreign subprograms // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, From 2efe5af9d576d30e93b71e16aba201acf9f795db Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Thu, 17 Sep 2026 16:01:12 +0200 Subject: [PATCH 26/28] Fall back to gcc on PATH for the Windows bridge builds Questa on Windows often bundles no MinGW gcc, and not every Aldec installation may either. The Windows builds now use CC, else the gcc bundled with the simulator (Questa: gcc-*-mingw64*, Riviera-PRO: mingw), else gcc on PATH. NVC and GHDL also build the library with that gcc when the package has no prebuilt DLL, instead of failing. A gcc build imports the Python DLL directly, so its directory is added to PATH for the simulator processes. The Riviera-PRO/Active-HDL build could not find Python.h: it looked for include and libs next to sys.executable, which in a virtual environment has neither, and passed paths wrapped in quotes that subprocess escaped and ccomp received literally. Both builds now take the headers and the import library from the base Python installation, and ccomp gets one plain argument per path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UHjuxyB7jZC8nMgrakXFj2 --- docs/python_bridge/user_guide.rst | 28 +++-- tests/unit/test_python_bridge.py | 116 +++++++++++++++++-- vunit/python_bridge/foreign_application.py | 23 ++-- vunit/python_bridge/native_library.py | 124 ++++++++++++++------- 4 files changed, 227 insertions(+), 64 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 151eba241..24877ea0f 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -56,9 +56,11 @@ Requirements (``--enable-shared``), which is the case for distribution Pythons, ``actions/setup-python``, ``uv`` and ``pyenv`` builds with default settings. * Windows: a 64-bit CPython from python.org (or compatible, such as - ``actions/setup-python`` and ``uv``). No compiler is needed for NVC and GHDL; - Questa uses the MinGW compiler it ships with. MSYS2/MinGW Pythons are not - supported. + ``actions/setup-python`` and ``uv``). No compiler is needed for NVC and GHDL + when the VUnit package includes the prebuilt DLLs. Otherwise, and always for + Questa and Riviera-PRO/Active-HDL, a MinGW-w64 ``gcc`` is needed: ``CC``, the + one bundled with the simulator if there is one, or ``gcc`` on ``PATH``. + MSYS2/MinGW Pythons are not supported. The simulator runs Python in the same environment as VUnit itself, including an active virtual environment and its installed packages. @@ -608,7 +610,10 @@ Other simulators Riviera-PRO/Active-HDL (VHPI) implement ``python_ffi_pkg`` with a VHPI application, built from the C sources in :vunit_file:`vunit/python_bridge/native/vhpi -` with their ``ccomp`` driver. :meth:`add_python() +` with their ``ccomp`` driver. On Windows, +``ccomp`` is given the directory of the gcc bundled with the simulator +(``/mingw``) or, when there is none, of ``gcc`` on ``PATH``. +:meth:`add_python() ` builds the application under the output path (``//libraries``) the first time it is called and rebuilds it when the sources, the Python running VUnit or the simulator @@ -668,11 +673,16 @@ Windows minor version (``vunit/python_bridge/bin``). The matching DLL is copied to the output path and nothing is compiled. A development checkout of VUnit does not contain the DLLs; they can be built with - ``tools/build_python_bridge.py`` from an MSVC developer prompt. For Questa - the library must be linked against the simulator's ``mtipli``, so it is - built on first use with the MinGW GCC bundled with Questa, against the - headers and the import library of the Python running VUnit. That build is - untested. + ``tools/build_python_bridge.py`` from an MSVC developer prompt, and without + them the library is built with gcc like for Questa. For Questa the library + must be linked against the simulator's ``mtipli``, so it is always built on + first use with gcc, against the headers and the import library of the + Python running VUnit. The compiler is ``CC`` if set, else the MinGW gcc + bundled with the simulator (``gcc-*-mingw64*`` next to the Questa + executables' directory), else ``gcc`` on ``PATH``. Many Questa installations + on Windows bundle none. A gcc build imports the Python DLL directly, so + :meth:`add_python() ` adds its directory to + ``PATH`` for the simulator processes. These builds are untested. The bridge uses the full (version specific) CPython ABI rather than the Stable ABI since embedding the interpreter in the environment VUnit runs in diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py index 85676d5d4..fb77b7a23 100644 --- a/tests/unit/test_python_bridge.py +++ b/tests/unit/test_python_bridge.py @@ -716,21 +716,20 @@ def test_new_directory_when_content_changes(self): second = self._prepare(tempdir, b"version-two") self.assertNotEqual(first.parent, second.parent) - def test_missing_dll_for_running_version_raises_actionable_error(self): - root = None + def test_missing_dll_for_running_version_builds_with_gcc(self): with create_tempdir() as tempdir: binary_path = tempdir / "bin" binary_path.mkdir() root = tempdir / "root" with ( mock.patch("vunit.python_bridge.native_library.BINARY_PATH", binary_path), - mock.patch("sys.version_info", (3, 12)), mock.patch("vunit.python_bridge.native_library.sysconfig.get_platform", return_value="win-amd64"), - mock.patch("subprocess.run") as run_mock, + mock.patch.object(native_library, "_build_windows_library", return_value=Path("built.dll")) as build, ): - with self.assertRaisesRegex(RuntimeError, "No prebuilt Python bridge DLL"): - native_library._prepare_windows_library(root) # pylint: disable=protected-access - run_mock.assert_not_called() + self.assertEqual( + native_library._prepare_windows_library(root), Path("built.dll") # pylint: disable=protected-access + ) + build.assert_called_once_with(root) def test_non_win_amd64_platform_raises(self): with mock.patch("vunit.python_bridge.native_library.sysconfig.get_platform", return_value="mingw"): @@ -738,6 +737,94 @@ def test_non_win_amd64_platform_raises(self): native_library._prepare_windows_library(Path("root")) # pylint: disable=protected-access +class TestWindowsGccBuild(unittest.TestCase): + """ + Compiler lookup and gcc builds on Windows, testable on any platform with the lookups patched. + """ + + @staticmethod + def _touch(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + return path + + def test_windows_gcc_prefers_cc_then_bundled_then_path(self): + with create_tempdir() as tempdir: + prefix = tempdir / "questa" / "win64" + with mock.patch.dict(os.environ, {"CC": "my-gcc -m64"}): + self.assertEqual(native_library.windows_gcc(prefix), ["my-gcc", "-m64"]) + with ( + mock.patch.dict(os.environ, {"CC": ""}), + mock.patch("shutil.which", return_value="C:/mingw/bin/gcc.exe"), + ): + self.assertEqual(native_library.windows_gcc(prefix), ["C:/mingw/bin/gcc.exe"]) + self.assertEqual(native_library.windows_gcc(None), ["C:/mingw/bin/gcc.exe"]) + questa_gcc = self._touch(tempdir / "questa" / "gcc-7.4.0-mingw64vc16" / "bin" / "gcc.exe") + self.assertEqual(native_library.windows_gcc(prefix), [str(questa_gcc)]) + riviera_prefix = tempdir / "riviera" / "bin" + riviera_gcc = self._touch(tempdir / "riviera" / "mingw" / "bin" / "gcc.exe") + self.assertEqual(native_library.windows_gcc(riviera_prefix), [str(riviera_gcc)]) + with mock.patch.dict(os.environ, {"CC": ""}), mock.patch("shutil.which", return_value=None): + self.assertIsNone(native_library.windows_gcc(tempdir / "none" / "bin")) + + def test_build_without_gcc_raises_actionable_error(self): + with mock.patch.object(native_library, "windows_gcc", return_value=None): + with self.assertRaisesRegex(native_library.PythonBridgeError, "MinGW-w64.*PATH.*CC"): + native_library._build_windows_library( + Path("root"), Path("questa/win64") + ) # pylint: disable=protected-access + + def _build_cmd(self, tempdir, simulator_prefix): + with ( + mock.patch.object(native_library, "windows_gcc", return_value=["gcc"]), + mock.patch.object(native_library, "python_dev_paths", return_value=("C:/Py/include", Path("C:/Py/libs"))), + mock.patch.object(native_library, "_fli_include_dir", return_value="C:/questa/include"), + mock.patch.object(native_library, "compile_library", side_effect=lambda cmd, tmp, target: target) as build, + ): + target = native_library._build_windows_library( + tempdir, simulator_prefix + ) # pylint: disable=protected-access + return target, build.call_args[0][0] + + def test_fli_build_adds_front_end_and_mtipli(self): + with create_tempdir() as tempdir: + target, cmd = self._build_cmd(tempdir, tempdir / "questa" / "win64") + self.assertEqual(target.name, "vunit_python_bridge_fli.dll") + self.assertIn("-lmtipli", cmd) + self.assertIn("-IC:/questa/include", cmd) + self.assertIn(str(native_library.NATIVE_PATH / "fli.c"), cmd) + self.assertIn(f"-lpython{sys.version_info[0]}{sys.version_info[1]}", cmd) + + def test_vhpidirect_build_has_no_fli_parts(self): + with create_tempdir() as tempdir: + target, cmd = self._build_cmd(tempdir, None) + self.assertEqual(target.name, "vunit_python_bridge.dll") + self.assertNotIn("-lmtipli", cmd) + self.assertNotIn(str(native_library.NATIVE_PATH / "fli.c"), cmd) + self.assertIn("-IC:/Py/include", cmd) + + def test_python_dev_paths_use_the_base_installation(self): + # A virtual environment has neither include nor libs next to its python.exe + with create_tempdir() as tempdir: + include = self._touch(tempdir / "base" / "Include" / "Python.h").parent + with ( + mock.patch("sys.platform", "win32"), + mock.patch("sys.base_prefix", str(tempdir / "base")), + mock.patch("sys.executable", str(tempdir / "venv" / "Scripts" / "python.exe")), + mock.patch.object(native_library.sysconfig, "get_paths", return_value={"include": str(include)}), + ): + self.assertEqual(native_library.python_dev_paths(), (str(include), tempdir / "base" / "libs")) + + def test_add_python_dll_to_path_appends_once(self): + with ( + mock.patch.object(native_library, "windows_python_dll", return_value=os.path.join("pydir", "python3.dll")), + mock.patch.dict(os.environ, {"PATH": "first"}), + ): + native_library.add_python_dll_to_path() + native_library.add_python_dll_to_path() + self.assertEqual(os.environ["PATH"], os.pathsep.join(["first", "pydir"])) + + class TestSimulatorHooks(unittest.TestCase): """ nvc_run_flags / ghdl_elab_flags / ghdl_run_env / modelsim_vsim_flags @@ -946,6 +1033,21 @@ def fake_build(target, sources, simulator_prefix): # pylint: disable=unused-arg foreign_application.setup_vhpi_application(output_path, other) self.assertEqual(build.call_count, 1) + def test_ccomp_gets_one_unquoted_argument_per_path(self): + with ( + mock.patch("sys.platform", "linux"), + mock.patch.object(foreign_application, "python_dev_paths", return_value=("/py/include", Path("/py/lib"))), + mock.patch.object(foreign_application, "compile_library") as build, + ): + foreign_application._build_vhpi( # pylint: disable=protected-access + Path("/out dir/python.dll"), [Path("/src/a.c"), Path("/src/b.c")], Path("/riviera/bin") + ) + args = build.call_args[0][0] + self.assertEqual(args[args.index("-o") + 1], str(Path("/out dir/python.dll"))) + self.assertIn("/py/include", args) + self.assertEqual(args[-2:], [str(Path("/src/a.c")), str(Path("/src/b.c"))]) + self.assertFalse(any('"' in arg for arg in args)) + def test_failed_build_leaves_no_fingerprint(self): with create_tempdir() as tempdir: output_path = Path(tempdir) / "out" diff --git a/vunit/python_bridge/foreign_application.py b/vunit/python_bridge/foreign_application.py index c22fc3327..c17d6e7e3 100644 --- a/vunit/python_bridge/foreign_application.py +++ b/vunit/python_bridge/foreign_application.py @@ -14,10 +14,11 @@ """ from pathlib import Path +import os import sys import hashlib -from .native_library import compile_library +from .native_library import add_python_dll_to_path, compile_library, python_dev_paths, windows_gcc SRC_PATH = Path(__file__).parent.resolve() / "native" / "vhpi" @@ -58,11 +59,19 @@ def _build_vhpi(target, sources, simulator_prefix): """ Compile the VHPI application with the ccomp compiler driver of Riviera-PRO/Active-HDL. """ - python_include = Path(sys.executable).parent.resolve() / "include" - python_libs = Path(sys.executable).parent.resolve() / "libs" + python_include, python_libs = python_dev_paths() ccomp = simulator_prefix / ("ccomp.exe" if sys.platform == "win32" else "ccomp") - args = [str(ccomp), "-vhpi", "-dbg", "-verbose", "-o", f'"{target}"'] + env = None + if sys.platform == "win32": + add_python_dll_to_path() + # Riviera-PRO bundles a MinGW gcc under /mingw, other installations may not. + # ponytail: untested (no Aldec license), assumes ccomp finds gcc on PATH when none is bundled + gcc = windows_gcc(simulator_prefix) + if gcc is not None: + env = dict(os.environ, PATH=os.pathsep.join([str(Path(gcc[0]).parent), os.environ.get("PATH", "")])) + # One argument per item: subprocess quotes them, quotes added here would reach ccomp literally + args = [str(ccomp), "-vhpi", "-dbg", "-verbose", "-o", str(target)] args += ["-l", f"python{sys.version_info[0]}{sys.version_info[1]}", "-l", "python3", "-l", "_tkinter"] - args += ["-I", f'"{python_include}"', "-I", f'"{SRC_PATH}"', "-L", f'"{python_libs}"'] - args += [" ".join(f'"{path}"' for path in sources)] - compile_library(args, target, target) + args += ["-I", str(python_include), "-I", str(SRC_PATH), "-L", str(python_libs)] + args += [str(path) for path in sources] + compile_library(args, target, target, env) diff --git a/vunit/python_bridge/native_library.py b/vunit/python_bridge/native_library.py index 29ca3f4d3..a0b880fb6 100644 --- a/vunit/python_bridge/native_library.py +++ b/vunit/python_bridge/native_library.py @@ -6,7 +6,8 @@ """ -The native bridge library: compiled and cached on Linux, prebuilt DLLs on Windows. +The native bridge library: compiled and cached on Linux, prebuilt DLLs on Windows, compiled +with gcc on Windows for Questa/ModelSim and when a DLL is missing. This module only depends on the standard library so that tools/build_python_bridge.py can load it without VUnit's dependencies. @@ -21,7 +22,7 @@ import sys import sysconfig from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Tuple PACKAGE_PATH = Path(__file__).parent.resolve() # C sources of the bridge library @@ -61,8 +62,9 @@ def prepare_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path None selects the VHPIDIRECT variant used by NVC and GHDL. """ if sys.platform == "win32": + add_python_dll_to_path() if simulator_prefix is not None: - return _build_windows_fli_library(root, simulator_prefix) + return _build_windows_library(root, simulator_prefix) return _prepare_windows_library(root) return _prepare_posix_library(root, simulator_prefix) @@ -95,7 +97,8 @@ def windows_python_dll() -> str: def _prepare_windows_library(root: Path) -> Path: """ - Select the prebuilt DLL for the running Python. Never compiles. + Select the prebuilt DLL for the running Python, or build the library with gcc when the + installation has none (e.g. a VUnit package built without the DLLs). """ if sysconfig.get_platform() != "win-amd64": raise PythonBridgeError( @@ -108,11 +111,7 @@ def _prepare_windows_library(root: Path) -> Path: name = windows_dll_name() source = BINARY_PATH / name if not source.is_file(): - raise PythonBridgeError( - f"No prebuilt Python bridge DLL for Python {sys.version_info[0]}.{sys.version_info[1]} " - f"({source!s} is missing). Released VUnit packages include the DLLs for the supported Python " - "versions. A development checkout can build them with tools/build_python_bridge.py (requires MSVC)." - ) + return _build_windows_library(root) data = source.read_bytes() directory = root / f"cp{sys.version_info[0]}{sys.version_info[1]}-win_amd64-{hashlib.sha256(data).hexdigest()[:12]}" target = directory / "vunit_python_bridge.dll" @@ -288,13 +287,13 @@ def _prepare_posix_library(root: Path, simulator_prefix: Optional[Path] = None) return compile_library(cmd, tmp, library_file) -def compile_library(cmd: List[str], tmp: Path, library_file: Path) -> Path: +def compile_library(cmd: List[str], tmp: Path, library_file: Path, env: Optional[Dict[str, str]] = None) -> Path: """ Run a compiler command building tmp and move the result to library_file, failing with the compiler output. """ try: - proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, env=env) except OSError as exc: raise PythonBridgeError(f"Failed to run the C compiler {cmd[0]!r}: {exc}") from exc if proc.returncode != 0: @@ -310,52 +309,95 @@ def compile_library(cmd: List[str], tmp: Path, library_file: Path) -> Path: return library_file -def _questa_mingw_gcc(simulator_prefix: Path) -> str: +def windows_gcc(simulator_prefix: Optional[Path] = None) -> Optional[List[str]]: """ - The MinGW gcc bundled with Questa/ModelSim, used for FLI applications on Windows. + The C compiler command for builds on Windows, or None: CC, else the MinGW gcc bundled with + the simulator (Questa/ModelSim: gcc-*-mingw64*, Riviera-PRO: mingw), else gcc on PATH. + Not every installation bundles a gcc, e.g. Questa on Windows often does not. """ - matches = sorted(simulator_prefix.parent.glob("gcc*mingw64*")) - for match in matches: - gcc = match / "bin" / "gcc.exe" - if gcc.is_file(): - return str(gcc.resolve()) - raise PythonBridgeError( - "VHDL Python support on Windows needs the MinGW GCC bundled with Questa/ModelSim to build " - f"the Python bridge library, but it was not found in {simulator_prefix.parent!s}" + if os.environ.get("CC"): + return shlex.split(os.environ["CC"]) + if simulator_prefix is not None: + install = Path(simulator_prefix).resolve().parent + bundled = sorted(install.glob("gcc*mingw64*/bin/gcc.exe")) + sorted(install.glob("mingw*/bin/gcc.exe")) + if bundled: + return [str(bundled[0])] + gcc = shutil.which("gcc") + return [gcc] if gcc else None + + +def python_dev_paths() -> Tuple[str, Path]: + """ + The directory of Python.h and of the Python library to link against, for the Python running + VUnit. Both belong to the base installation, also when VUnit runs in a virtual environment, + which has neither. + """ + include = next( + path + for path in _include_dirs([sysconfig.get_paths()["include"], str(Path(sys.base_prefix) / "include")]) + if (Path(path) / "Python.h").is_file() ) + if sys.platform == "win32": + return include, Path(sys.base_prefix) / "libs" + return include, Path(sysconfig.get_config_var("LIBDIR") or Path(sys.base_prefix) / "lib") -def _build_windows_fli_library(root: Path, simulator_prefix: Path) -> Path: +def add_python_dll_to_path() -> None: + """ + Libraries built with gcc on Windows import the Python DLL directly (the prebuilt DLLs load it + themselves), so the simulator processes VUnit starts must find it through PATH. """ - Build the FLI variant on Windows with the MinGW gcc bundled with Questa/ModelSim, against the - headers and the import library of the Python running VUnit. The prebuilt DLLs cannot be used: - they are MSVC builds without the FLI front end, which has to be linked against the simulator's - own libmtipli. Reuses a cached build like the POSIX one. + # ponytail: changes PATH of the whole VUnit process, only for its children to find pythonXY.dll + directory = str(Path(windows_python_dll()).parent) + paths = os.environ.get("PATH", "").split(os.pathsep) + if directory not in paths: + os.environ["PATH"] = os.pathsep.join([item for item in paths if item] + [directory]) + - Untested: this environment has no Windows installation of Questa. +def _build_windows_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path: """ - simulator_prefix = Path(simulator_prefix).resolve() - include = _fli_include_dir(simulator_prefix) - gcc = _questa_mingw_gcc(simulator_prefix) - python_home = Path(sys.executable).parent.resolve() - python_include = _include_dirs([str(python_home / "include")])[0] - python_libs = python_home / "libs" + Build the library on Windows with gcc (see windows_gcc) against the headers and the import + library of the Python running VUnit, reusing a cached build like the POSIX one. - key_items = [_source_fingerprint(), sys.version, str(python_home), str(simulator_prefix), gcc] + With simulator_prefix, the FLI variant for Questa/ModelSim: the prebuilt DLLs cannot be used + for it, since they are MSVC builds without the FLI front end, which has to be linked against + the simulator's own libmtipli. Without, the VHPIDIRECT variant for NVC and GHDL, built only + when there is no prebuilt DLL. + + Untested: this environment has no Windows installation. + """ + fli = simulator_prefix is not None + compiler = windows_gcc(simulator_prefix) + if compiler is None: + bundled = "" + if simulator_prefix is not None: + bundled = f"the simulator installation ({Path(simulator_prefix).resolve().parent!s}) bundles none and " + raise PythonBridgeError( + "VHDL Python support on Windows needs gcc (MinGW-w64) to build the Python bridge library, but " + f"{bundled}there is no gcc on PATH. Install MinGW-w64 and put its bin directory on PATH, " + "or set the CC environment variable." + ) + python_include, python_libs = python_dev_paths() + include_dirs = [python_include] + ([_fli_include_dir(simulator_prefix)] if simulator_prefix is not None else []) + + name = "vunit_python_bridge_fli.dll" if fli else "vunit_python_bridge.dll" + key_items = [_source_fingerprint(), sys.version, sys.base_prefix, str(simulator_prefix), " ".join(compiler)] key = hashlib.sha256("\n".join(key_items).encode("utf-8")).hexdigest()[:16] - directory = root / f"cp{sys.version_info[0]}{sys.version_info[1]}-win_amd64-fli-{key}" - library_file = directory / "vunit_python_bridge_fli.dll" + directory = root / f"cp{sys.version_info[0]}{sys.version_info[1]}-win_amd64-{'fli' if fli else 'gcc'}-{key}" + library_file = directory / name if library_file.is_file(): return library_file directory.mkdir(parents=True, exist_ok=True) - tmp = directory / f"vunit_python_bridge_fli.dll.{os.getpid()}.tmp" + tmp = directory / f"{name}.{os.getpid()}.tmp" cmd = ( - [gcc, "-shared", "-m64", "-O2", "-D__USE_MINGW_ANSI_STDIO=1", "-freg-struct-return"] - + [f"-I{include}", f"-I{python_include}"] - + [str(path) for path in bridge_sources(fli=True)] + compiler + + ["-shared", "-m64", "-O2", "-D__USE_MINGW_ANSI_STDIO=1"] + + (["-freg-struct-return"] if fli else []) + + [f"-I{path}" for path in include_dirs] + + [str(path) for path in bridge_sources(fli=fli)] + ["-o", str(tmp)] + [f"-L{python_libs!s}", f"-lpython{sys.version_info[0]}{sys.version_info[1]}"] - + [f"-L{simulator_prefix!s}", "-lmtipli"] + + ([f"-L{Path(simulator_prefix).resolve()!s}", "-lmtipli"] if simulator_prefix is not None else []) ) return compile_library(cmd, tmp, library_file) From 25693bba8978d81927f20b8a4bab7cb6d5783abd Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Thu, 17 Sep 2026 16:16:41 +0200 Subject: [PATCH 27/28] Require a 64-bit simulator for VHDL Python support on Windows The bridge library and the Python DLL it embeds are loaded into the simulator process, and only 64-bit Python is supported. A 32-bit or ARM64 simulator failed late with an obscure load error. add_python() now reads the machine type from the PE header of the simulator executable (nvc.exe, ghdl.exe, or vsim.exe for Questa/ModelSim and Riviera-PRO/Active-HDL) and rejects anything but x64, as it already did for Python. An executable that cannot be read is not checked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UHjuxyB7jZC8nMgrakXFj2 --- docs/python_bridge/user_guide.rst | 5 +- tests/unit/test_python_bridge.py | 69 ++++++++++++++++++++++ vunit/python_bridge/bridge.py | 2 + vunit/python_bridge/foreign_application.py | 9 ++- vunit/python_bridge/native_library.py | 58 +++++++++++++++++- 5 files changed, 138 insertions(+), 5 deletions(-) diff --git a/docs/python_bridge/user_guide.rst b/docs/python_bridge/user_guide.rst index 24877ea0f..adb421117 100644 --- a/docs/python_bridge/user_guide.rst +++ b/docs/python_bridge/user_guide.rst @@ -60,7 +60,10 @@ Requirements when the VUnit package includes the prebuilt DLLs. Otherwise, and always for Questa and Riviera-PRO/Active-HDL, a MinGW-w64 ``gcc`` is needed: ``CC``, the one bundled with the simulator if there is one, or ``gcc`` on ``PATH``. - MSYS2/MinGW Pythons are not supported. + MSYS2/MinGW Pythons are not supported. The simulator must be a 64-bit (x64) + build too, since the Python DLL is loaded into its process; + :meth:`add_python() ` rejects 32-bit and ARM64 + simulators and Pythons. The simulator runs Python in the same environment as VUnit itself, including an active virtual environment and its installed packages. diff --git a/tests/unit/test_python_bridge.py b/tests/unit/test_python_bridge.py index fb77b7a23..6249ae556 100644 --- a/tests/unit/test_python_bridge.py +++ b/tests/unit/test_python_bridge.py @@ -825,6 +825,75 @@ def test_add_python_dll_to_path_appends_once(self): self.assertEqual(os.environ["PATH"], os.pathsep.join(["first", "pydir"])) +class TestWindows64Bit(unittest.TestCase): + """ + Python and the simulator must be 64-bit x86 on Windows. Testable on any platform with the + platform patched and fake executables carrying only a DOS and PE header. + """ + + class _Simulator(_FakeSimulator): + executable = "ghdl" + + @staticmethod + def _write_executable(path, machine): + header = bytearray(0x100) + header[0:2] = b"MZ" + header[0x3C:0x40] = (0x80).to_bytes(4, "little") + header[0x80:0x84] = b"PE\0\0" + header[0x84:0x86] = machine.to_bytes(2, "little") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(bytes(header)) + + def _check(self, simulator): + with ( + mock.patch("sys.platform", "win32"), + mock.patch.object(native_library.sysconfig, "get_platform", return_value="win-amd64"), + ): + native_library.check_windows_64bit_simulator(simulator) + + def test_pe_machine(self): + with create_tempdir() as tempdir: + self._write_executable(tempdir / "x64.exe", 0x8664) + (tempdir / "text.exe").write_bytes(b"not an executable") + self.assertEqual(native_library.pe_machine(tempdir / "x64.exe"), 0x8664) + self.assertIsNone(native_library.pe_machine(tempdir / "text.exe")) + self.assertIsNone(native_library.pe_machine(tempdir / "missing.exe")) + + def test_accepts_x64_and_unreadable_simulators(self): + with create_tempdir() as tempdir: + self._write_executable(tempdir / "x64" / "ghdl.exe", 0x8664) + self._check(self._Simulator("ghdl", prefix=str(tempdir / "x64"))) + self._check(self._Simulator("ghdl", prefix=str(tempdir / "missing"))) + self._check(self._Simulator("ghdl", prefix=None)) + + def test_rejects_32_bit_and_arm64_simulators(self): + with create_tempdir() as tempdir: + for machine, kind in ((0x014C, "32-bit x86"), (0xAA64, "ARM64")): + self._write_executable(tempdir / kind / "ghdl.exe", machine) + with self.assertRaisesRegex(native_library.PythonBridgeError, f"64-bit.*ghdl.exe is {kind}"): + self._check(self._Simulator("ghdl", prefix=str(tempdir / kind))) + + def test_vsim_is_checked_for_simulators_without_executable_attribute(self): + with create_tempdir() as tempdir: + self._write_executable(tempdir / "vsim.exe", 0x014C) + with self.assertRaisesRegex(native_library.PythonBridgeError, "vsim.exe is 32-bit x86"): + self._check(_FakeSimulator("modelsim", prefix=str(tempdir))) + + def test_not_checked_outside_windows(self): + with create_tempdir() as tempdir: + self._write_executable(tempdir / "ghdl.exe", 0x014C) + with mock.patch("sys.platform", "linux"): + native_library.check_windows_64bit_simulator(self._Simulator("ghdl", prefix=str(tempdir))) + + def test_rejects_32_bit_python(self): + with ( + mock.patch("sys.platform", "win32"), + mock.patch.object(native_library.sysconfig, "get_platform", return_value="win32"), + ): + with self.assertRaisesRegex(native_library.PythonBridgeError, "64-bit .*CPython"): + native_library.check_windows_64bit_simulator(self._Simulator("ghdl")) + + class TestSimulatorHooks(unittest.TestCase): """ nvc_run_flags / ghdl_elab_flags / ghdl_run_env / modelsim_vsim_flags diff --git a/vunit/python_bridge/bridge.py b/vunit/python_bridge/bridge.py index f13cb89ca..b74ef695f 100644 --- a/vunit/python_bridge/bridge.py +++ b/vunit/python_bridge/bridge.py @@ -20,6 +20,7 @@ PACKAGE_PATH, PythonBridgeError, check_python_build, + check_windows_64bit_simulator, prepare_library, windows_python_dll, ) @@ -71,6 +72,7 @@ def setup(project, output_path: str, simulator_class, run_script_path: Optional[ ) check_python_build() + check_windows_64bit_simulator(simulator_class) is_fli = simulator_name in FLI_SIMULATORS root = Path(output_path) / "python_bridge" diff --git a/vunit/python_bridge/foreign_application.py b/vunit/python_bridge/foreign_application.py index c17d6e7e3..374cc6596 100644 --- a/vunit/python_bridge/foreign_application.py +++ b/vunit/python_bridge/foreign_application.py @@ -18,7 +18,13 @@ import sys import hashlib -from .native_library import add_python_dll_to_path, compile_library, python_dev_paths, windows_gcc +from .native_library import ( + add_python_dll_to_path, + check_windows_64bit_simulator, + compile_library, + python_dev_paths, + windows_gcc, +) SRC_PATH = Path(__file__).parent.resolve() / "native" / "vhpi" @@ -28,6 +34,7 @@ def setup_vhpi_application(output_path, simulator_class): Build the VHPI application for Riviera-PRO/Active-HDL under the output path, unless the one already there was built from the same sources for the same Python and simulator. """ + check_windows_64bit_simulator(simulator_class) target = Path(output_path) / simulator_class.name / "libraries" / "python.dll" sources = [SRC_PATH / "python_pkg_vhpi.c", SRC_PATH / "python_pkg.c"] simulator_prefix = Path(simulator_class.find_prefix()).resolve() diff --git a/vunit/python_bridge/native_library.py b/vunit/python_bridge/native_library.py index a0b880fb6..56c14a4be 100644 --- a/vunit/python_bridge/native_library.py +++ b/vunit/python_bridge/native_library.py @@ -95,16 +95,68 @@ def windows_python_dll() -> str: return buffer.value -def _prepare_windows_library(root: Path) -> Path: +def _check_windows_python() -> None: """ - Select the prebuilt DLL for the running Python, or build the library with gcc when the - installation has none (e.g. a VUnit package built without the DLLs). + Reject a Python that is not a 64-bit x86 (win-amd64) build. """ if sysconfig.get_platform() != "win-amd64": raise PythonBridgeError( "VHDL Python support on Windows requires a 64-bit (x86-64) python.org style CPython, " f"{sys.executable} is built for {sysconfig.get_platform()}" ) + + +# Machine field values of the PE (Windows executable) header +_PE_MACHINE_X64 = 0x8664 +_PE_MACHINE_NAMES = {_PE_MACHINE_X64: "64-bit x86 (x64)", 0x014C: "32-bit x86", 0xAA64: "ARM64"} + + +def pe_machine(path: Path) -> Optional[int]: + """ + The machine type in the PE header of a Windows executable, or None if it has no readable one. + """ + try: + with open(path, "rb") as fptr: + header = fptr.read(4096) + except OSError: + return None + offset = int.from_bytes(header[0x3C:0x40], "little") + if header[:2] != b"MZ" or header[offset : offset + 4] != b"PE\0\0": + return None + return int.from_bytes(header[offset + 4 : offset + 6], "little") + + +def check_windows_64bit_simulator(simulator_class) -> None: + """ + On Windows, reject a Python or simulator that is not 64-bit x86. The bridge library and the + Python DLL it embeds are loaded into the simulator process, so all three must match, and + only 64-bit Python is supported. A mismatch otherwise fails late with an obscure load error. + A simulator executable that cannot be read is not checked. + """ + if sys.platform != "win32": + return + _check_windows_python() + prefix = simulator_class.find_prefix() + if prefix is None: + return + # NVC and GHDL name their executable, the Questa/ModelSim and Aldec interfaces use vsim + name = getattr(simulator_class, "executable", "vsim") + executable = Path(prefix) / (name if name.lower().endswith(".exe") else f"{name}.exe") + machine = pe_machine(executable) + if machine is not None and machine != _PE_MACHINE_X64: + kind = _PE_MACHINE_NAMES.get(machine, f"machine type 0x{machine:04x}") + raise PythonBridgeError( + f"VHDL Python support requires a 64-bit (x64) simulator, but {executable!s} is {kind}. " + "Use a 64-bit installation of the simulator." + ) + + +def _prepare_windows_library(root: Path) -> Path: + """ + Select the prebuilt DLL for the running Python, or build the library with gcc when the + installation has none (e.g. a VUnit package built without the DLLs). + """ + _check_windows_python() if hasattr(sys, "gettotalrefcount"): raise PythonBridgeError("VHDL Python support does not provide bridge DLLs for debug builds of CPython") From 128da0a3a7dfff671cfd137c165d539c15249a2b Mon Sep 17 00:00:00 2001 From: Sebastian Hellgren Date: Thu, 17 Sep 2026 16:25:04 +0200 Subject: [PATCH 28/28] Test the gcc build of the bridge on Windows CI A Windows job without the prebuilt DLLs builds the bridge with the MinGW gcc of the runner image and runs the feature tests and the example with NVC. It exercises the build Questa/ModelSim always uses on Windows, for which there is no runner. Every Windows job also checks the PE machine type of the simulator (x64) and of a 32-bit executable of Windows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UHjuxyB7jZC8nMgrakXFj2 --- .github/workflows/python_bridge.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python_bridge.yml b/.github/workflows/python_bridge.yml index e19376849..8e53002cb 100644 --- a/.github/workflows/python_bridge.yml +++ b/.github/workflows/python_bridge.yml @@ -121,7 +121,12 @@ jobs: matrix: py: ['3.10', '3.12', '3.14'] sim: [nvc, ghdl] - name: '🟦 Windows · Python ${{ matrix.py }} · ${{ matrix.sim }}' + bridge: [dll] + # No prebuilt DLLs: the library is built with the MinGW gcc of the runner image, the + # build Questa/ModelSim always uses on Windows, which has no runner + include: + - {py: '3.13', sim: nvc, bridge: gcc} + name: '🟦 Windows · Python ${{ matrix.py }} · ${{ matrix.sim }} · ${{ matrix.bridge }}' env: VUNIT_SIMULATOR: ${{ matrix.sim }} steps: @@ -132,11 +137,16 @@ jobs: submodules: recursive - name: '📥 Download bridge DLLs' + if: matrix.bridge == 'dll' uses: actions/download-artifact@v4 with: name: python-bridge-dlls path: vunit/python_bridge/bin + - name: '⚙️ Put MinGW gcc on PATH' + if: matrix.bridge == 'gcc' + run: echo "C:/mingw64/bin" >> "$GITHUB_PATH" + - name: '🐍 Setup Python' uses: actions/setup-python@v6 with: @@ -165,11 +175,22 @@ jobs: echo "$RUNNER_TEMP/venv with space/Scripts" >> "$GITHUB_PATH" "$RUNNER_TEMP/venv with space/Scripts/pip" install -e . --progress-bar off - # No compiler on PATH: the prebuilt DLLs must be used + # The simulator is accepted as x64, and a 32-bit executable of Windows itself is detected + - name: '🔍 64-bit check' + run: | + python -c "import shutil; from vunit.python_bridge.native_library import pe_machine; \ + assert pe_machine(shutil.which('${{ matrix.sim }}')) == 0x8664; \ + assert pe_machine(r'C:\Windows\SysWOW64\cmd.exe') == 0x14C" + + # dll: no MSVC on PATH, the prebuilt DLLs must be used. gcc: the library must be built with gcc. - name: '🚧 Feature tests' run: | ! command -v cl + if [ '${{ matrix.bridge }}' = gcc ]; then command -v gcc; fi python vunit/vhdl/python/run.py -p 4 -o "$RUNNER_TEMP/out dir" + if [ '${{ matrix.bridge }}' = gcc ]; then + find "$RUNNER_TEMP/out dir" -path '*-win_amd64-gcc-*' -name vunit_python_bridge.dll | grep . + fi - name: '🚧 Example' run: python examples/vhdl/embedded_python/run.py -p 4 --without-attributes .optional_deps --without-attributes .expected_failure