diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 792a2a1..1e16696 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -121,7 +121,7 @@ constant used when the parameter is omitted runs in ``__main__``. ... exec("x = 1", session => golden); - exec_file("fixed_point.py", session => fixed_point); + exec_file("fixed_point.py", session => fixed_point); -- /fixed_point.py expected := eval("model(x)", session => golden); got := call("model", arg(x), session => fixed_point); @@ -183,7 +183,8 @@ functions, classes and variables, are separate. Everything else is shared: 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. + and open files are process wide. The current directory is the working + directory of the simulator, see :ref:`python_bridge:file_paths`. * 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. @@ -446,7 +447,9 @@ file: 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``. +name (without extension), so the run script is normally named ``run.py``. The +run script is found by its absolute path, and its directory is on ``sys.path`` +so that it can import its siblings, see :ref:`python_bridge:file_paths`. 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 ``vu.main()``) must be behind @@ -461,13 +464,14 @@ import_module_from_file and to_py_list_str taking an optional :ref:`session ` parameter), and is what ``import_run_script`` uses internally. Like the file name of ``exec_file``, a relative path is relative to the directory of the testbench -file, ``tb_path``, so ``test_runner_setup`` must have been called; this works -from verification components without ``runner_cfg``. An absolute path is used -as it is given: +file, ``tb_path``, and needs ``test_runner_setup`` to have completed; an +absolute path is used as it is given. See :ref:`python_bridge:file_paths` for +importing a file at any other time, for example from a verification +component: .. code-block:: vhdl - import_module_from_file("reference_model.py", "reference_model"); + import_module_from_file("reference_model.py", "reference_model"); -- /reference_model.py exec("reference_model.configure(gain=4)"); ``to_py_list_str`` converts an ``integer_vector``, ``integer_vector_ptr_t`` or @@ -586,10 +590,227 @@ 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 testbench file -(``tb_path``); an absolute path is used as given. 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``, and needs ``test_runner_setup`` to have completed; an absolute +file name is used as given. See :ref:`python_bridge:file_paths` for loading a +file at any other time, for example from a verification component. Like the +other operations, it takes an optional trailing :ref:`session +` parameter. + +.. _python_bridge:file_paths: + +File paths +---------- + +A file name can be resolved against several directories, depending on who +resolves it: ``python_pkg`` in VHDL, or Python code in the model. This section +lists them all, gives recipes for the common cases and describes ``base_path``, +which gives verification components a directory of their own. + +How a file name is resolved +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - File name + - Resolved against + * - Relative name given to ``exec_file`` or ``import_module_from_file`` + after ``test_runner_setup`` has completed + - The directory of the testbench file, ``tb_path``. + * - The same before ``test_runner_setup`` has completed, or without a + test runner + - Nothing: it is reported as a failure, which suggests calling it after + ``test_runner_setup``, giving an absolute name or joining it to + ``base_path``. + * - Absolute name given to ``exec_file`` or ``import_module_from_file`` + - Used as it is given. + * - ``join(base_path, "x")``, at any time, time 0 included + - The base directory set by ``set_relative_file_base`` in the run script + or by the environment variable ``VUNIT_PYTHON_BRIDGE_FILE_BASE``, which + ``base_path`` returns. Without a base directory ``base_path`` is + reported as a failure. + * - Relative name opened by Python code: ``open``, ``numpy.load``, + ``pathlib.Path`` and so on + - The current working directory of Python, which is the working + directory of the simulator. See the warning below. + * - Python module imported by name, ``import model`` + - ``sys.path``, which holds the directory of the run script first, like + ``python run.py`` does, the directories of ``PYTHONPATH`` (the + simulator inherits the environment of VUnit, so a ``PYTHONPATH`` set + in the run script before ``vu.main()`` is seen), and, while + ``exec_file`` executes a file, the directory of that file. + +.. warning:: + + The working directory of the simulator differs between simulators. On NVC + and GHDL it is the directory the run script was started from. On + Questa/ModelSim it is the ``modelsim`` directory of the VUnit output path, + for example ``vunit_out/modelsim``. Other simulators may use yet another + directory. Never rely on a relative file name in Python code: build it from + ``Path(__file__).parent`` in the model, or pass an absolute name from VHDL, + built with ``base_path`` or ``tb_path``. + +Recipes +~~~~~~~ + +**A Python file next to the testbench.** Give its name relative to the +testbench file, after ``test_runner_setup``: + +.. code-block:: vhdl + + test_runner_setup(runner, runner_cfg); + exec_file("model.py"); -- /model.py + +**The model of a verification component.** A verification component is not +tied to a testbench and often loads its model at time 0, before +``test_runner_setup``. Give its name relative to a base directory set in the +run script. With this layout: + +.. code-block:: text + + project/ + run.py + vc/ + uart_vc.vhd + models/ + uart_model.py + helpers.py + tb/ + tb_uart.vhd + +the run script sets its own directory as the base directory, which makes the +names independent of the directory the run script is started from: + +.. code-block:: python + + import vunit_python_bridge + + # project/, whatever the working directory + vunit_python_bridge.set_relative_file_base(Path(__file__).parent) + vu.add_package("vunit-python-bridge", allow_setup=True) + +.. code-block:: vhdl + + exec_file(join(base_path, "vc/models/uart_model.py"), session); -- project/vc/models/uart_model.py + import_module_from_file(join(base_path, "vc/models/helpers.py"), "helpers", session); -- project/vc/models/helpers.py + +Alternatively, import the model by module name from a directory on +``sys.path``: the directory of the run script, or one added to ``PYTHONPATH`` +in the run script before ``vu.main()``: + +.. code-block:: python + + # project/vc/models on sys.path of the simulator + os.environ["PYTHONPATH"] = str(Path(__file__).parent / "vc" / "models") + +.. code-block:: vhdl + + exec("import uart_model", session); -- found on sys.path: project/vc/models/uart_model.py + +Or wait for ``test_runner_setup`` to complete and give the name relative to the +testbench file: + +.. code-block:: vhdl + + if get_phase <= test_runner_setup then + wait on runner until get_phase > test_runner_setup; + end if; + exec_file("uart_model.py", session); -- /uart_model.py + +``wait_until(runner, test_runner_setup)`` is not enough, since the phase is +entered before the testbench path is set. + +**Data files used by a model.** Find them relative to the model file in +Python: + +.. code-block:: python + + from pathlib import Path + + # Next to this file, whatever the working directory of the simulator + COEFFICIENTS = np.load(Path(__file__).parent / "coefficients.npy") + +or pass an absolute name from VHDL: + +.. code-block:: vhdl + + -- project/vc/data/coefficients.npy, an absolute name for Python + call("model.load_coefficients", arg(join(base_path, "vc/data/coefficients.npy"))); + +**A run script shared through a git submodule.** When the run script cannot be +changed, set the base directory with the environment variable, for example from +a Makefile at the root of the repository: + +.. code-block:: text + + repo/ + Makefile + sub/ (git submodule) + run.py + vc/ + models/ + vc_model.py + tb/ + tb_vc.vhd + +.. code-block:: make + + # repo/ is the base directory + sim: + VUNIT_PYTHON_BRIDGE_FILE_BASE=$(CURDIR) python sub/run.py + +The file names joined to ``base_path`` are then relative to the repository +root: + +.. code-block:: vhdl + + exec_file(join(base_path, "sub/vc/models/vc_model.py")); -- repo/sub/vc/models/vc_model.py + +base_path and the base directory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``base_path`` returns the base directory, absolute, for VHDL to join file +names to with ``join`` of VUnit's ``path`` package, which ``vunit_context`` +includes, the same way as ``tb_path(runner_cfg)``. It is a function without +arguments: the base directory is a constant of a package generated when the +package is set up, so it can be used at any time, before ``test_runner_setup`` +too. Without a base directory ``base_path`` is reported as a failure on +``python_logger``, naming both ways of setting it, and returns ``""``. +``base_path`` serves any file name passed to a Python model, not only those of +``exec_file`` and ``import_module_from_file``, whose own relative file names +are still taken from the directory of the testbench. + +``vunit_python_bridge.set_relative_file_base(directory)`` sets the base +directory in the run script. It must be called before +``vu.add_package("vunit-python-bridge", ...)``; a call after it raises a +``RuntimeError``. The directory, a ``str`` or a ``Path``, is resolved when the +function is called, a relative one against the current working directory, and +a directory that does not exist raises a ``ValueError``. + +The environment variable ``VUNIT_PYTHON_BRIDGE_FILE_BASE`` sets the base +directory without changing the run script. It is read when the package is set +up, during ``add_package``. A relative value is resolved against the current +working directory at that time, an empty value counts as unset, and a value +that is not an existing directory makes the setup fail with an error naming +the variable and the value. + +The variable takes precedence, so that whoever runs a shared run script can +override the directory it sets: + +========================== ==================== ========================== +``set_relative_file_base`` Environment variable Base directory +========================== ==================== ========================== +not called unset or empty none, ``base_path`` fails +called unset or empty that of the call +not called set that of the variable +called set that of the variable +========================== ==================== ========================== + +When both are set to different directories, compared after resolving them, +the setup logs a warning naming both and saying that the environment variable +takes precedence. .. _python_bridge:semantics: @@ -653,7 +874,10 @@ arguments, a 20 register status dump, wide ``import_module_from_file``, two models loaded into a session each, and a 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. +the Python function in ``python_model.py`` rather than VHDL. The component finds +that file by joining its name to ``base_path``, the directory of the run script, which +``run.py`` sets as the base directory, while the testbench gives the files it +loads itself relative to its own directory. See :ref:`python_bridge:file_paths`. .. _python_bridge:native: diff --git a/examples/embedded_python/python_model.vhd b/examples/embedded_python/python_model.vhd index dff00c1..1d5ab96 100644 --- a/examples/embedded_python/python_model.vhd +++ b/examples/embedded_python/python_model.vhd @@ -6,8 +6,17 @@ -- -- The behaviour of this component is a Python function, loaded from -- model_file. The VHDL only passes values between the ports and the model. +-- +-- model_file is relative to the base directory set by +-- vunit_python_bridge.set_relative_file_base() in the run script, or by the +-- environment variable VUNIT_PYTHON_BRIDGE_FILE_BASE, and joined to that +-- directory, which base_path returns. Unlike a name relative to the testbench, which needs +-- test_runner_setup to have completed, this works at any time, time 0 +-- included, and does not depend on which testbench instantiates the +-- component. library vunit_lib; +use vunit_lib.path.all; library python_bridge; context python_bridge.python_context; @@ -26,7 +35,7 @@ begin begin -- Wait for the first input so that the model is only loaded if used wait on x; - exec_file(model_file); + exec_file(join(base_path, model_file)); -- / loop y <= call("compute", arg(x)); diff --git a/examples/embedded_python/run.py b/examples/embedded_python/run.py index 1042d6d..7865c65 100644 --- a/examples/embedded_python/run.py +++ b/examples/embedded_python/run.py @@ -27,6 +27,8 @@ from pathlib import Path from vunit import VUnit +import vunit_python_bridge + # Test, the module it imports and the package that provides it OPTIONAL_PACKAGES = [ ("Test GUI browsing for input stimuli file", "PySimpleGUI", "PySimpleGUI"), @@ -106,6 +108,12 @@ def main(): vu = VUnit.from_argv() vu.add_vhdl_builtins() + # The base directory that base_path returns, which python_model uses to find its Python model at any + # time and from any testbench. It is the directory of this script rather than Path.cwd(), so + # the example runs from any working directory. A shared run script that cannot be changed can + # leave this out and have it set by the environment variable VUNIT_PYTHON_BRIDGE_FILE_BASE. + # It must be set before add_package. + vunit_python_bridge.set_relative_file_base(root) vu.add_package("vunit-python-bridge", allow_setup=True) vu.add_random() # The setup function of the package builds the foreign language application of the simulator diff --git a/examples/embedded_python/tb_example.vhd b/examples/embedded_python/tb_example.vhd index df63c80..cf4118f 100644 --- a/examples/embedded_python/tb_example.vhd +++ b/examples/embedded_python/tb_example.vhd @@ -154,10 +154,13 @@ begin end; -- The model of the device used in some of the examples below is the Python - -- module device_model.py, next to this testbench + -- module device_model.py, next to this testbench. Like every relative file + -- name the testbench gives exec_file and import_module_from_file, it is + -- relative to the directory of this testbench file, which needs + -- test_runner_setup to have completed. procedure import_device_model is begin - import_module_from_file(join(tb_path(runner_cfg), "device_model.py"), "device_model"); + import_module_from_file("device_model.py", "device_model"); -- /device_model.py end; begin @@ -313,7 +316,7 @@ begin -- exec_file executes the file in the namespace we have been using all along, -- 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"); + exec_file("bump.py"); -- /bump.py check_equal(integer'(call("bump")), 1); check_equal(integer'(call("bump")), 2); @@ -322,7 +325,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(join(tb_path(runner_cfg), "bump.py"), "bumper"); + import_module_from_file("bump.py", "bumper"); -- /bump.py check_equal(integer'(call("bumper.bump")), 1); check_equal(integer'(call("bumper.bump")), 2); @@ -335,8 +338,8 @@ begin -- 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); + exec_file("golden_model.py", golden); -- /golden_model.py + exec_file("fixed_point_model.py", fixed_point); -- /fixed_point_model.py check_equal(eval_string("config['name']", golden), "golden"); check_equal(eval_string("config['name']", fixed_point), "fixed_point"); @@ -513,7 +516,8 @@ begin elsif run("Test globbing for files") then -- glob finds all files matching a pattern, in this case all Python files in the directory tree - -- rooted in the testbench directory + -- rooted in the testbench directory. The pattern is absolute, built from tb_path, since Python + -- resolves a relative one against its working directory, which differs between simulators. exec("from glob import glob"); exec("python_files = glob('" & join(tb_path(runner_cfg), "**", "*.py") & "', recursive=True)"); @@ -593,7 +597,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(join(tb_path(runner_cfg), "image_model.py"), "image_model"); + import_module_from_file("image_model.py", "image_model"); -- /image_model.py -- 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); @@ -770,6 +774,6 @@ begin -- A component whose behaviour is a Python function python_model_inst : entity work.python_model - generic map(model_file => join(tb_path(runner_cfg), "python_model.py")) + generic map(model_file => "python_model.py") port map(x => model_x, y => model_y); end; diff --git a/release_notes/0.2.0.md b/release_notes/0.2.0.md index 7de24f5..8590ea0 100644 --- a/release_notes/0.2.0.md +++ b/release_notes/0.2.0.md @@ -1,3 +1,4 @@ ## Changes - `import_module_from_file` resolves a relative path against the directory of the testbench file, like `exec_file`, rather than against the working directory of the simulator. It must be called after `test_runner_setup`. Callers that relied on a path relative to the working directory must pass an absolute path instead. See #7. +- A relative file name given to `exec_file` or `import_module_from_file` before `test_runner_setup` has completed is reported as a failure, instead of being taken from the working directory of the simulator. The new `base_path` returns a base directory set with `vunit_python_bridge.set_relative_file_base()` or `VUNIT_PYTHON_BRIDGE_FILE_BASE`, for file names that work at any time, for example in verification components. diff --git a/src/vunit_python_bridge/__init__.py b/src/vunit_python_bridge/__init__.py index 1bec107..0cc6475 100644 --- a/src/vunit_python_bridge/__init__.py +++ b/src/vunit_python_bridge/__init__.py @@ -25,10 +25,18 @@ * foreign_application: builds the VHPI application. * simulator_hooks: makes NVC and GHDL find the library. * runtime: runs inside the simulator's embedded interpreter. +* file_base: the base directory of ``base_path``, ``set_relative_file_base``. + +``set_relative_file_base(directory)``, called in the run script before ``add_package``, sets the +directory that ``base_path`` of ``python_pkg`` returns, for VHDL to join file names to. The +environment variable ``VUNIT_PYTHON_BRIDGE_FILE_BASE`` sets it too, and takes precedence. """ from pathlib import Path +# set_relative_file_base and FILE_BASE_VARIABLE are public: run scripts use them +from .file_base import FILE_BASE_VARIABLE, add_file_base_package, set_relative_file_base + VHDL_PATH = Path(__file__).parent.resolve() / "hdl" / "src" # The foreign language interface implementing the package for a simulator. VUnit says which @@ -73,6 +81,8 @@ def setup(context): f"it has no foreign language interface for {simulator_name}" ) + add_file_base_package(context) + if interface == "VHPI": # Riviera-PRO/Active-HDL, the simulators served by the VHPI application context.add_source_files(context.library.name, [VHDL_PATH / "python_pkg_vhpi.vhd"]) diff --git a/src/vunit_python_bridge/file_base.py b/src/vunit_python_bridge/file_base.py new file mode 100644 index 0000000..ba729bc --- /dev/null +++ b/src/vunit_python_bridge/file_base.py @@ -0,0 +1,128 @@ +# 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 base directory that ``base_path`` of ``python_pkg`` returns: set by ``set_relative_file_base`` +in the run script or by the environment variable ``VUNIT_PYTHON_BRIDGE_FILE_BASE``, and given to +VHDL in a generated package. +""" + +import logging +import os +from pathlib import Path + +from .bridge import _write_if_changed + +# Name of the generated VHDL package holding the base directory, used by python_pkg (base_path) +FILE_BASE_PACKAGE = "python_file_base_pkg" + +# The environment variable setting the directory of base_path when the run script cannot. It takes +# precedence over set_relative_file_base so that whoever runs a shared run script can override it. +FILE_BASE_VARIABLE = "VUNIT_PYTHON_BRIDGE_FILE_BASE" + +LOGGER = logging.getLogger(__name__) + +# The directory set by set_relative_file_base, None when unset, and whether setup has run, after +# which the directory is in the generated package and can no longer be changed +_relative_file_base = None +_setup_done = False + + +def set_relative_file_base(directory): + """ + Set the directory that ``base_path`` of ``python_pkg`` returns, for VHDL to join file names to. + + Call it in the run script before ``vu.add_package("vunit-python-bridge", ...)``. A relative + directory is taken from the current working directory at the time of the call. + + :param directory: An existing directory, a ``str`` or a ``Path``. + """ + global _relative_file_base # pylint: disable=global-statement + + if _setup_done: + raise RuntimeError( + 'set_relative_file_base() must be called before vu.add_package("vunit-python-bridge", ...), ' + "the package has already been set up" + ) + path = Path(directory).resolve() + if not path.is_dir(): + raise ValueError(f"The relative file base {str(directory)!r} is not an existing directory") + _relative_file_base = path + + +def _vhdl_string_literal(text): + """ + A VHDL expression of type string for the UTF-8 bytes of a text, in ASCII: printable characters + in a literal with its quotes doubled, other bytes as character'val(n). + """ + parts = [] + literal = "" + for byte in text.encode("utf-8"): + if 0x20 <= byte <= 0x7E: + literal += '""' if byte == ord('"') else chr(byte) + else: + if literal: + parts.append(f'"{literal}"') + literal = "" + parts.append(f"character'val({byte})") + if literal or not parts: + parts.append(f'"{literal}"') + if not parts[0].startswith('"'): + # A string expression rather than a lone character + parts.insert(0, '""') + return " & ".join(parts) + + +def _file_base_package_text(directory): + """ + Content of the generated python_file_base_pkg.vhd. + """ + value = "" if directory is None else directory.as_posix() + return f"""\ +-- Generated by vunit_python_bridge.setup. Do not edit. +-- +-- The directory set by the environment variable {FILE_BASE_VARIABLE} or by +-- vunit_python_bridge.set_relative_file_base() in the run script, empty when neither is set. +-- base_path of python_pkg returns it. +package {FILE_BASE_PACKAGE} is + constant p_relative_file_base : string := {_vhdl_string_literal(value)}; +end package; +""" + + +def _file_base(): + """ + The directory of base_path: that of the environment variable if it is set and not empty, + otherwise that of set_relative_file_base, None when neither is set. + """ + value = os.environ.get(FILE_BASE_VARIABLE, "") + if value == "": + return _relative_file_base + + directory = Path(value).resolve() + if not directory.is_dir(): + raise ValueError(f"The environment variable {FILE_BASE_VARIABLE}={value!r} is not an existing directory") + if _relative_file_base is not None and _relative_file_base != directory: + LOGGER.warning( + "The environment variable %s=%s takes precedence over set_relative_file_base(%r)", + FILE_BASE_VARIABLE, + value, + str(_relative_file_base), + ) + return directory + + +def add_file_base_package(context): + """ + Generate python_file_base_pkg and add it to the library of the package. + """ + global _setup_done # pylint: disable=global-statement + + path = Path(context.output_path) / "python_bridge" / "vhdl" / f"{FILE_BASE_PACKAGE}.vhd" + _write_if_changed(path, _file_base_package_text(_file_base())) + context.add_source_files(context.library.name, [path]) + _setup_done = True + diff --git a/src/vunit_python_bridge/hdl/src/python_pkg.vhd b/src/vunit_python_bridge/hdl/src/python_pkg.vhd index e703595..04c659d 100644 --- a/src/vunit_python_bridge/hdl/src/python_pkg.vhd +++ b/src/vunit_python_bridge/hdl/src/python_pkg.vhd @@ -7,6 +7,7 @@ -- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com use work.python_ffi_pkg.all; +use work.python_file_base_pkg.all; library vunit_lib; use vunit_lib.path.all; use vunit_lib.run_pkg.all; @@ -274,26 +275,36 @@ package python_pkg is -- 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); + + -- The absolute base directory, set by vunit_python_bridge.set_relative_file_base() + -- or VUNIT_PYTHON_BRIDGE_FILE_BASE. Without one, a failure is reported on + -- python_logger and "" is returned. + impure function base_path return string; end package; package body python_pkg is - impure function p_file_path(file_name : string) return string; + impure function p_file_path(file_name, operation : string; session : python_session_t) return string; -- @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 path : string := p_file_path( + module_path, "import_module_from_file(""" & module_path & """, """ & as_module_name & """)", session + ); 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('" & p_file_path(module_path) & "')))" & LF & + spec_name & " = spec_from_file_location('" & as_module_name & "', str(Path('" & 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); + if path /= "" then + exec(code, session); + end if; end; -- @formatter:on @@ -901,18 +912,48 @@ package body python_pkg is -- The file name of exec_file and import_module_from_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 + impure function p_file_path(file_name, operation : string; session : python_session_t) return string is + constant tb_dir : string := tb_path(get_cfg(runner_state)); begin - if p_is_absolute(file_name) then + if file_name = "" then + failure(get_logger(get_id(session)), operation & " failed:" & LF & "Empty file name."); + return ""; + elsif p_is_absolute(file_name) then return file_name; + elsif tb_dir = "" then + -- Without a testbench path the file would silently be taken from the working directory + failure( + get_logger(get_id(session)), + operation & " failed:" & LF & + "A relative file name needs the testbench path set by test_runner_setup. " & + "Call it after test_runner_setup, give an absolute path, " & + "or join it to base_path with a base directory set in the run script." + ); + return ""; end if; - return join(tb_path(get_cfg(runner_state)), file_name); + return join(tb_dir, file_name); + end; + + impure function base_path return string is + begin + if p_relative_file_base = "" then + failure( + python_logger, + "base_path failed: no base directory is set. " & + "Call vunit_python_bridge.set_relative_file_base() in the run script before add_package, " & + "or set the environment variable VUNIT_PYTHON_BRIDGE_FILE_BASE." + ); + end if; + return p_relative_file_base; end; procedure exec_file(file_name : string; session : python_session_t := default_session) is + constant path : string := p_file_path(file_name, p_exec_file_operation(file_name, session), session); variable ok : boolean; begin - ok := p_exec_file(p_file_path(file_name), session); + if path /= "" then + ok := p_exec_file(path, session); + end if; end; ----------------------------------------------------------------------------- diff --git a/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd b/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd index f8274a1..0512c4a 100644 --- a/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd +++ b/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd @@ -106,7 +106,10 @@ package python_ffi_pkg is constant p_kind_integer_vector : integer := 9; constant p_kind_real_vector : integer := 10; - -- Name of the operation, used in the error messages + -- Names of the operations, used in the error messages + impure function p_exec_file_operation( + file_name : 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 @@ -216,6 +219,16 @@ package body python_ffi_pkg is failure(python_logger, name & " requires NVC, GHDL or Questa"); end; + impure function p_exec_file_operation( + file_name : string; session : python_session_t := default_session + ) return string is + begin + if p_is_default(session) then + return "exec_file(""" & file_name & """)"; + end if; + return "exec_file(""" & file_name & """, session => """ & name(session) & """)"; + end; + impure function p_eval_operation(expr : string; session : python_session_t := default_session) return string is begin if p_is_default(session) then diff --git a/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in b/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in index 7c2e311..a97979e 100644 --- a/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in +++ b/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in @@ -7,6 +7,7 @@ -- Copyright (c) 2014-2026, Lars Asplund lars.anders.asplund@gmail.com use work.python_ffi_pkg.all; +use work.python_file_base_pkg.all; library vunit_lib; use vunit_lib.path.all; use vunit_lib.run_pkg.all; @@ -176,26 +177,36 @@ $call_declarations -- 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); + + -- The absolute base directory, set by vunit_python_bridge.set_relative_file_base() + -- or VUNIT_PYTHON_BRIDGE_FILE_BASE. Without one, a failure is reported on + -- python_logger and "" is returned. + impure function base_path return string; end package; package body python_pkg is - impure function p_file_path(file_name : string) return string; + impure function p_file_path(file_name, operation : string; session : python_session_t) return string; -- @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 path : string := p_file_path( + module_path, "import_module_from_file(""" & module_path & """, """ & as_module_name & """)", session + ); 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('" & p_file_path(module_path) & "')))" & LF & + spec_name & " = spec_from_file_location('" & as_module_name & "', str(Path('" & 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); + if path /= "" then + exec(code, session); + end if; end; -- @formatter:on @@ -745,18 +756,48 @@ $arg_functions -- The file name of exec_file and import_module_from_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 + impure function p_file_path(file_name, operation : string; session : python_session_t) return string is + constant tb_dir : string := tb_path(get_cfg(runner_state)); begin - if p_is_absolute(file_name) then + if file_name = "" then + failure(get_logger(get_id(session)), operation & " failed:" & LF & "Empty file name."); + return ""; + elsif p_is_absolute(file_name) then return file_name; + elsif tb_dir = "" then + -- Without a testbench path the file would silently be taken from the working directory + failure( + get_logger(get_id(session)), + operation & " failed:" & LF & + "A relative file name needs the testbench path set by test_runner_setup. " & + "Call it after test_runner_setup, give an absolute path, " & + "or join it to base_path with a base directory set in the run script." + ); + return ""; end if; - return join(tb_path(get_cfg(runner_state)), file_name); + return join(tb_dir, file_name); + end; + + impure function base_path return string is + begin + if p_relative_file_base = "" then + failure( + python_logger, + "base_path failed: no base directory is set. " & + "Call vunit_python_bridge.set_relative_file_base() in the run script before add_package, " & + "or set the environment variable VUNIT_PYTHON_BRIDGE_FILE_BASE." + ); + end if; + return p_relative_file_base; end; procedure exec_file(file_name : string; session : python_session_t := default_session) is + constant path : string := p_file_path(file_name, p_exec_file_operation(file_name, session), session); variable ok : boolean; begin - ok := p_exec_file(p_file_path(file_name), session); + if path /= "" then + ok := p_exec_file(path, session); + end if; end; ----------------------------------------------------------------------------- diff --git a/tests/file_base/local_model.py b/tests/file_base/local_model.py new file mode 100644 index 0000000..5447c79 --- /dev/null +++ b/tests/file_base/local_model.py @@ -0,0 +1,17 @@ +# 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 next to the testbench, executed by a relative file name of exec_file. +""" + +from pathlib import Path + +LOCAL_DIR = Path(__file__).parent.as_posix() + + +def get_local_dir(): + return LOCAL_DIR diff --git a/tests/file_base/run.py b/tests/file_base/run.py new file mode 100644 index 0000000..dcf1a32 --- /dev/null +++ b/tests/file_base/run.py @@ -0,0 +1,45 @@ +# 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 tests of base_path with a base directory set by +set_relative_file_base(), or by the environment variable +VUNIT_PYTHON_BRIDGE_FILE_BASE when that is set. It is a run of its own since +the base directory is fixed for a run, and tests/run.py starts it after its own +tests, once for each way. +""" + +import os +from pathlib import Path + +from vunit import VUnit + +import vunit_python_bridge + +ROOT = Path(__file__).parent + +# The tests directory, not the directory of the testbench +BASE_DIR = ROOT.parent + + +def main(): + vu = VUnit.from_argv() + vu.add_vhdl_builtins() + # tests/run.py runs this once with the base directory set here and once with it set by + # the environment variable instead + if vunit_python_bridge.FILE_BASE_VARIABLE not in os.environ: + vunit_python_bridge.set_relative_file_base(BASE_DIR) + vu.add_package("vunit-python-bridge", allow_setup=True) + + lib = vu.add_library("lib") + lib.add_source_file(ROOT / "tb_file_base.vhd") + lib.test_bench("tb_file_base").set_generic("base_dir", BASE_DIR.resolve().as_posix()) + + vu.main() + + +if __name__ == "__main__": + main() diff --git a/tests/file_base/tb_file_base.vhd b/tests/file_base/tb_file_base.vhd new file mode 100644 index 0000000..239aad0 --- /dev/null +++ b/tests/file_base/tb_file_base.vhd @@ -0,0 +1,118 @@ +-- 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 file names: base_path with a base directory set in the run script, +-- the tests directory, which is not the directory of this testbench, and +-- relative and empty file names of exec_file and import_module_from_file. + +library vunit_lib; +context vunit_lib.vunit_context; +library python_bridge; +context python_bridge.python_context; + +entity tb_file_base is + generic ( + runner_cfg : string; + base_dir : string + ); +end entity; + +architecture tb of tb_file_base is +begin + main : process + constant vc_session : python_session_t := new_session("vc"); + constant default_logger : logger_t := get_logger(get_id(default_session)); + constant before_setup_failure : string := + "A relative file name needs the testbench path set by test_runner_setup. " & + "Call it after test_runner_setup, give an absolute path, " & + "or join it to base_path with a base directory set in the run script."; + variable checked_before_setup : boolean := false; + begin + -- Before test_runner_setup, when the testbench path is not yet set + if find(runner_cfg, "Test base_path before test_runner_setup") > 0 then + exec_file(join(base_path, "models/reference_model.py")); + import_module_from_file(join(base_path, "models/filters.py"), "filters_model"); + end if; + -- Relative file names before test_runner_setup, as from a verification component at time 0 + if find(runner_cfg, "Test relative file names before test_runner_setup") > 0 then + mock(default_logger, failure); + exec_file("local_model.py"); + check_only_log(default_logger, "exec_file(""local_model.py"") failed:" & LF & before_setup_failure, failure); + import_module_from_file("local_model.py", "local_model"); + check_only_log( + default_logger, + "import_module_from_file(""local_model.py"", ""local_model"") failed:" & LF & before_setup_failure, + failure + ); + unmock(default_logger); + checked_before_setup := true; + end if; + test_runner_setup(runner, runner_cfg); + + while test_suite loop + if run("Test base_path from a verification component at time 0") then + -- The verification component loaded its model at time 0 + wait for 1 ns; + check_equal(call_string("get_model_dir", session => vc_session), join(base_dir, "models")); + check_equal(integer'(eval("filters_model.fir(1)", session => vc_session)), 2); + + elsif run("Test base_path before test_runner_setup") then + check_equal(call_string("get_model_dir"), join(base_dir, "models")); + check_equal(integer'(eval("filters_model.fir(4)")), 5); + + elsif run("Test base_path after test_runner_setup") then + check_equal(base_path, base_dir); + exec_file(join(base_path, "models/reference_model.py")); + check_equal(call_string("get_model_dir"), join(base_dir, "models")); + import_module_from_file(join(base_path, "models/filters.py"), "filters_model"); + check_equal(integer'(eval("filters_model.fir(2)")), 3); + + elsif run("Test a relative file name of exec_file is relative to the testbench") then + exec_file("local_model.py"); + check_equal(call_string("get_local_dir") & "/", tb_path(runner_cfg)); + + elsif run("Test relative file names before test_runner_setup") then + check_true(checked_before_setup); + -- Nothing was executed or imported + check_false(eval_boolean("'get_local_dir' in globals() or 'local_model' in globals()")); + + elsif run("Test a relative file name from a verification component waiting for test_runner_setup") then + -- The verification component loaded its model once test_runner_setup had completed + wait for 1 ns; + check_equal(call_string("get_local_dir", session => vc_session) & "/", tb_path(runner_cfg)); + + elsif run("Test an empty file name") then + mock(default_logger, failure); + exec_file(""); + check_only_log(default_logger, "exec_file("""") failed:" & LF & "Empty file name.", failure); + import_module_from_file("", "x"); + check_only_log(default_logger, "import_module_from_file("""", ""x"") failed:" & LF & "Empty file name.", failure); + unmock(default_logger); + check_false(eval_boolean("'x' in globals()")); + end if; + end loop; + + test_runner_cleanup(runner); + end process; + + -- A verification component loading its model: at time 0, before test_runner_setup has set + -- the testbench path, through base_path, or by a relative file name once test_runner_setup + -- has completed + vc : process + constant session : python_session_t := new_session("vc"); + begin + if find(runner_cfg, "Test base_path from a verification component at time 0") > 0 then + exec_file(join(base_path, "models/reference_model.py"), session); + import_module_from_file(join(base_path, "models/filters.py"), "filters_model", session); + elsif find(runner_cfg, "Test a relative file name from a verification component waiting for test_runner_setup") > 0 then + if get_phase <= test_runner_setup then + wait on runner until get_phase > test_runner_setup; + end if; + exec_file("local_model.py", session); + end if; + wait; + end process; +end architecture; diff --git a/tests/run.py b/tests/run.py index f91833b..a6ffc9d 100644 --- a/tests/run.py +++ b/tests/run.py @@ -11,8 +11,12 @@ everything but remote_test is guarded by a __main__ check. """ +import os +import subprocess +import sys from pathlib import Path from vunit import VUnit, VUnitCLI +from vunit_python_bridge import FILE_BASE_VARIABLE ROOT = Path(__file__).parent @@ -67,10 +71,36 @@ def verify(results, expected_failures): print(f"Verified {len(expected)} expected failures: {', '.join(name.split('.')[-1] for name in expected)}") +def run_file_base_tests(args): + """ + Run the tests of base_path with a base directory, which is fixed for a run and + therefore a run of its own, with the same simulator and an output path of its own. + It runs twice: with the base directory set by set_relative_file_base() in the run + script, and with it set by the environment variable instead. + """ + for name, env in [ + ("function", dict(os.environ)), + ("variable", dict(os.environ, **{FILE_BASE_VARIABLE: str(ROOT.resolve())})), + ]: + command = [ + sys.executable, + str(ROOT / "file_base" / "run.py"), + "-o", + str(Path(args.output_path) / "file_base" / name), + "-p", + str(args.num_threads), + ] + sys.stdout.flush() + if subprocess.run(command, env=env, check=False).returncode != 0: + raise RuntimeError(f"The tests of tests/file_base/run.py with the base directory set by the {name} failed") + + def main(): args = VUnitCLI().parse_args() # The expected failures must not make the run script fail args.exit_0 = True + # The tests of base_path without a base directory need the variable unset + os.environ.pop(FILE_BASE_VARIABLE, None) vu = VUnit.from_args(args) vu.add_vhdl_builtins() vu.add_package("vunit-python-bridge", allow_setup=True) @@ -87,7 +117,11 @@ def main(): 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)) + def post_run(results): + verify(results, expected_failures) + run_file_base_tests(args) + + vu.main(post_run=post_run) if __name__ == "__main__": diff --git a/tests/tb_python_pkg_bridge.vhd b/tests/tb_python_pkg_bridge.vhd index f99f28e..84d1fbc 100644 --- a/tests/tb_python_pkg_bridge.vhd +++ b/tests/tb_python_pkg_bridge.vhd @@ -204,6 +204,19 @@ begin exec_file("models/reference_model.py"); check_equal(call_string("get_model_dir"), join(tb_path(runner_cfg), "models")); + elsif run("Test base_path without a base directory") then + -- The run script of these tests sets no base directory + mock(python_logger, failure); + check_equal(base_path, ""); + check_only_log( + python_logger, + "base_path failed: no base directory is set. " & + "Call vunit_python_bridge.set_relative_file_base() in the run script before add_package, " & + "or set the environment variable VUNIT_PYTHON_BRIDGE_FILE_BASE.", + failure + ); + unmock(python_logger); + elsif run("Test executing a file with an absolute file name") then exec_file(join(tb_path(runner_cfg), "models/reference_model.py")); check_equal(call_string("get_model_dir"), join(tb_path(runner_cfg), "models")); diff --git a/tests/test_python_bridge.py b/tests/test_python_bridge.py index 8563c0d..9aaf478 100644 --- a/tests/test_python_bridge.py +++ b/tests/test_python_bridge.py @@ -29,7 +29,7 @@ import tomli as tomllib # A dependency of VUnit before Python 3.11 import vunit_python_bridge -from vunit_python_bridge import bridge as bridge_setup, foreign_application, native_library, simulator_hooks +from vunit_python_bridge import bridge as bridge_setup, file_base, foreign_application, native_library, simulator_hooks PACKAGE_ROOT = Path(vunit_python_bridge.__file__).parent.resolve() MANIFEST = PACKAGE_ROOT / "vunit_pkg.toml" @@ -243,7 +243,7 @@ def _check_vhpi_application_built(self, name): setup_mock.assert_not_called() vhpi_mock.assert_called_once_with(context.output_path, name, context.simulator_prefix) - self.assertEqual(self._added_names(context), {"python_pkg_vhpi.vhd"}) + self.assertEqual(self._added_names(context), {"python_pkg_vhpi.vhd", "python_file_base_pkg.vhd"}) self.assertEqual({library for library, _ in context.added_files}, {"python_bridge"}) # The VHPI application needs no simulator hooks self.assertEqual(context.hooks, {}) @@ -344,6 +344,195 @@ def listing(): self.assertEqual(listing(), before) +class TestRelativeFileBase(unittest.TestCase): + """ + set_relative_file_base() and the python_file_base_pkg generated by setup(), giving + base_path of python_pkg its directory. + """ + + 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") + self._reset() + self.addCleanup(self._reset) + # The environment variable of the shell running the tests must not leak in + environ = mock.patch.dict(os.environ) + environ.start() + self.addCleanup(environ.stop) + os.environ.pop(vunit_python_bridge.FILE_BASE_VARIABLE, None) + + @staticmethod + def _reset(): + file_base._relative_file_base = None # pylint: disable=protected-access + file_base._setup_done = False # pylint: disable=protected-access + + def _setup(self, name="nvc"): + """ + Set the package up for a simulator, with bridge.setup() and the VHPI application stubbed, + and return the context and the generated package. + """ + simulator = mock.Mock() + simulator.name = name + context = _FakeContext(simulator, self.tempdir / "out", self.run_script, "/sim/bin") + fake_bridge = bridge_setup.PythonBridge(library_file=Path("/fake/libvunit_python_bridge.so"), vhdl_files=[]) + with ( + mock.patch("vunit_python_bridge.bridge.setup", return_value=fake_bridge), + mock.patch("vunit_python_bridge.foreign_application.setup_vhpi_application"), + mock.patch("vunit_python_bridge.simulator_hooks.register"), + ): + vunit_python_bridge.setup(context) + package = self.tempdir / "out" / "python_bridge" / "vhdl" / "python_file_base_pkg.vhd" + return context, package + + def _constant(self, package): + """ + The value expression of p_relative_file_base in the generated package. + """ + text = package.read_text(encoding="ascii") + match = re.search(r"constant p_relative_file_base : string := (.*);\n", text) + self.assertIsNotNone(match, text) + return match.group(1) + + def test_unset_gives_an_empty_constant(self): + _, package = self._setup() + self.assertIn("package python_file_base_pkg is", package.read_text(encoding="ascii")) + self.assertEqual(self._constant(package), '""') + + def test_set_gives_the_absolute_directory(self): + vunit_python_bridge.set_relative_file_base(self.tempdir) + _, package = self._setup() + self.assertEqual(self._constant(package), f'"{self.tempdir.as_posix()}"') + + def test_relative_directory_is_taken_from_the_working_directory(self): + (self.tempdir / "base").mkdir() + cwd = os.getcwd() + os.chdir(self.tempdir) + self.addCleanup(os.chdir, cwd) + vunit_python_bridge.set_relative_file_base("base") + # Changing the working directory later does not change the base directory + os.chdir(cwd) + _, package = self._setup() + self.assertEqual(self._constant(package), f'"{(self.tempdir / "base").as_posix()}"') + + def test_not_a_directory_is_rejected(self): + file_name = self.tempdir / "file.txt" + file_name.write_text("", encoding="utf-8") + for directory in (file_name, self.tempdir / "missing"): + with self.assertRaisesRegex(ValueError, "is not an existing directory"): + vunit_python_bridge.set_relative_file_base(directory) + + def test_setting_after_setup_is_rejected(self): + self._setup() + with self.assertRaisesRegex(RuntimeError, "must be called before"): + vunit_python_bridge.set_relative_file_base(self.tempdir) + + def test_quotes_are_doubled(self): + directory = self.tempdir / 'a"b' + directory.mkdir() + vunit_python_bridge.set_relative_file_base(directory) + _, package = self._setup() + self.assertEqual(self._constant(package), f'"{self.tempdir.as_posix()}/a""b"') + + def test_non_ascii_is_given_as_utf8_bytes(self): + directory = self.tempdir / "å b" + directory.mkdir() + vunit_python_bridge.set_relative_file_base(directory) + _, package = self._setup() + # The package itself is ASCII + package.read_bytes().decode("ascii") + self.assertEqual( + self._constant(package), f'"{self.tempdir.as_posix()}/" & character\'val(195) & character\'val(165) & " b"' + ) + + def test_literal_encoding(self): + # pylint: disable=protected-access + encode = file_base._vhdl_string_literal + self.assertEqual(encode(""), '""') + self.assertEqual(encode('"'), '""""') + # A string expression, not a lone character + self.assertEqual(encode("é"), "\"\" & character'val(195) & character'val(169)") + self.assertEqual(encode("a\tb"), '"a" & character\'val(9) & "b"') + + def _set_variable(self, value): + os.environ[vunit_python_bridge.FILE_BASE_VARIABLE] = str(value) + + def _literal(self, directory): + return f'"{directory.as_posix()}"' + + def test_variable_only(self): + self._set_variable(self.tempdir) + _, package = self._setup() + self.assertEqual(self._constant(package), self._literal(self.tempdir)) + + def test_variable_and_function_with_the_same_directory(self): + vunit_python_bridge.set_relative_file_base(self.tempdir) + # The same directory by another name, compared resolved + self._set_variable(self.tempdir / "." / "sub" / "..") + (self.tempdir / "sub").mkdir() + with self.assertNoLogs(file_base.LOGGER): + _, package = self._setup() + self.assertEqual(self._constant(package), self._literal(self.tempdir)) + + def test_variable_takes_precedence_over_the_function(self): + (self.tempdir / "function").mkdir() + (self.tempdir / "variable").mkdir() + vunit_python_bridge.set_relative_file_base(self.tempdir / "function") + self._set_variable(self.tempdir / "variable") + with self.assertLogs(file_base.LOGGER, "WARNING") as logs: + _, package = self._setup() + self.assertEqual(self._constant(package), self._literal(self.tempdir / "variable")) + self.assertEqual(len(logs.output), 1) + self.assertIn(vunit_python_bridge.FILE_BASE_VARIABLE, logs.output[0]) + self.assertIn(str(self.tempdir / "variable"), logs.output[0]) + self.assertIn(str(self.tempdir / "function"), logs.output[0]) + self.assertIn("takes precedence", logs.output[0]) + + def test_empty_variable_is_unset(self): + self._set_variable("") + _, package = self._setup() + self.assertEqual(self._constant(package), '""') + + self._reset() + vunit_python_bridge.set_relative_file_base(self.tempdir) + with self.assertNoLogs(file_base.LOGGER): + _, package = self._setup() + self.assertEqual(self._constant(package), self._literal(self.tempdir)) + + def test_relative_variable_is_taken_from_the_working_directory_at_setup(self): + (self.tempdir / "base").mkdir() + self._set_variable("base") + cwd = os.getcwd() + os.chdir(self.tempdir) + self.addCleanup(os.chdir, cwd) + _, package = self._setup() + self.assertEqual(self._constant(package), self._literal(self.tempdir / "base")) + + def test_variable_not_a_directory_is_rejected_at_setup(self): + missing = self.tempdir / "missing" + self._set_variable(missing) + with self.assertRaises(ValueError) as ctx: + self._setup() + self.assertIn(vunit_python_bridge.FILE_BASE_VARIABLE, str(ctx.exception)) + self.assertIn(str(missing), str(ctx.exception)) + self.assertIn("is not an existing directory", str(ctx.exception)) + + def test_variable_is_not_read_at_import(self): + self._set_variable(self.tempdir / "missing") + import importlib + + importlib.reload(vunit_python_bridge) + self.assertIsNone(file_base._relative_file_base) # pylint: disable=protected-access + + def test_bridge_and_vhpi_add_the_package(self): + for name in ("nvc", "ghdl", "modelsim", "rivierapro", "activehdl"): + self._reset() + context, package = self._setup(name) + self.assertIn(("python_bridge", package), context.added_files, name) + self.assertTrue(package.is_file(), name) + + class TestBridgePackageSubstitution(unittest.TestCase): """ Library token substitution into the generated python_bridge_pkg.vhd.