diff --git a/.github/workflows/python_bridge.yml b/.github/workflows/python_bridge.yml new file mode 100644 index 0000000..cb42842 --- /dev/null +++ b/.github/workflows/python_bridge.yml @@ -0,0 +1,265 @@ +name: 'python bridge' + +# Tests of the vunit-python-bridge package on CPython from python.org: Linux +# and macOS (bridge compiled on first use), Windows (prebuilt MSVC DLLs, and +# a gcc build without them), the Python unit tests, the content of the +# distribution, and the built wheel installed on every kind of system. +# +# VUnit is installed from the master branch of VUnit/vunit, which has the +# package support (VUnit/vunit#1221), until that is part of a VUnit release. + +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 + + - 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 the package 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 "vunit_hdl @ git+https://github.com/VUnit/vunit.git@master" --progress-bar off + "$RUNNER_TEMP/venv with space/bin/pip" install -e . pytest --progress-bar off + + - name: '🚧 Python unit tests' + run: python -m pytest tests/test_python_bridge.py + + - name: '🚧 Feature tests' + run: python tests/run.py -p 4 -o "$RUNNER_TEMP/out dir" + + - name: '🚧 Example' + run: python examples/embedded_python/run.py -p 4 --without-attributes .optional_deps --without-attributes .expected_failure + +# +# Windows, prebuilt MSVC DLLs, and a gcc build without them +# + + 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] + 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: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + + - name: '📥 Download bridge DLLs' + if: matrix.bridge == 'dll' + uses: actions/download-artifact@v4 + with: + name: python-bridge-dlls + path: src/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: + 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 the package 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 "vunit_hdl @ git+https://github.com/VUnit/vunit.git@master" --progress-bar off + "$RUNNER_TEMP/venv with space/Scripts/pip" install -e . --progress-bar off + + # 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 tests/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/embedded_python/run.py -p 4 --without-attributes .optional_deps --without-attributes .expected_failure + +# +# Packaging: one py3-none-any wheel with the sources and the DLLs, no other binary +# + + packaging: + needs: dlls + runs-on: ubuntu-latest + name: '📦 Packaging' + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + + - name: '📥 Download bridge DLLs' + uses: actions/download-artifact@v4 + with: + name: python-bridge-dlls + path: src/vunit_python_bridge/bin + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: '📦 Build the wheel' + run: | + pip install -U build --progress-bar off + python -m build --wheel + + # The single wheel for every platform: sources of every kind, the manifest, the five + # DLLs, no other binary + - name: '🔍 Check distribution content' + run: | + # One wheel for every platform: pure Python, tagged py3-none-any + wheels=(dist/*.whl) + test "${#wheels[@]}" = 1 || { echo "expected one wheel: ${wheels[*]}"; exit 1; } + case "${wheels[0]}" in *-py3-none-any.whl) ;; *) echo "${wheels[0]} is not py3-none-any"; exit 1;; esac + unzip -Z1 "${wheels[0]}" > wheel.txt + # Every C source and every HDL source, the manifest and the Python modules + for file in $(git ls-files src/vunit_python_bridge | grep -E '/(native|hdl/src)/|\.py$|\.toml$' | sed 's#^src/##'); do + grep -qx "$file" wheel.txt || { echo "missing $file in the wheel"; exit 1; } + done + # The five DLLs and no other binary + dlls='^vunit_python_bridge/bin/vunit_python_bridge-cp31[0-4]-win_amd64\.dll$' + test "$(grep -c "$dlls" wheel.txt)" = 5 + test "$(grep -c '\.dll$' wheel.txt)" = 5 + if grep -qE '\.(so|dylib|pyd)$' wheel.txt; then echo "the wheel has a binary"; exit 1; fi + + - name: '📤 Upload the distribution' + uses: actions/upload-artifact@v4 + with: + name: distribution + path: dist/* + if-no-files-found: error + +# +# The wheel installed and tested on Linux, macOS and Windows, there with the DLLs only +# + + wheel: + needs: packaging + uses: ./.github/workflows/wheel_tests.yml + with: + artifact: distribution diff --git a/.github/workflows/python_bridge_dlls.yml b/.github/workflows/python_bridge_dlls.yml new file mode 100644 index 0000000..b04da19 --- /dev/null +++ b/.github/workflows/python_bridge_dlls.yml @@ -0,0 +1,63 @@ +name: 'python bridge dlls' + +# Builds the prebuilt Windows DLLs of the VUnit Python bridge with MSVC, one +# per CPython version. Called by python_bridge.yml and by the release workflow. + +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/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..43462ff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,181 @@ +name: 'release' + +# Release of the vunit-python-bridge package. Pushing a tag vX.Y.Z naming the version +# in pyproject.toml builds the distribution, smoke tests the built wheel, publishes it +# to PyPI with a trusted publisher and creates the GitHub release of the tag. +# +# workflow_dispatch does everything but publish: the distribution is left as an +# artifact of the run, which is how a release is rehearsed. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +defaults: + run: + shell: bash + +jobs: + +# +# The version to release, taken from pyproject.toml and checked against the tag +# + + version: + runs-on: ubuntu-latest + name: '🔍 Version' + outputs: + version: ${{ steps.version.outputs.version }} + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: '🔍 Check the version and the tag' + id: version + run: | + if [ "$GITHUB_REF_TYPE" = tag ]; then + python tools/release.py validate --tag "$GITHUB_REF_NAME" + else + python tools/release.py validate + fi + echo "version=$(python tools/release.py version)" >> "$GITHUB_OUTPUT" + +# +# The distribution: the prebuilt Windows DLLs, then the one wheel, py3-none-any, +# that carries them to every platform +# + + dlls: + needs: version + uses: ./.github/workflows/python_bridge_dlls.yml + + build: + needs: [version, dlls] + runs-on: ubuntu-latest + name: '📦 Build the distribution' + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + + - name: '📥 Download bridge DLLs' + uses: actions/download-artifact@v4 + with: + name: python-bridge-dlls + path: src/vunit_python_bridge/bin + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: '📦 Build the wheel' + run: | + pip install -U build --progress-bar off + python -m build --wheel + + # As in python_bridge.yml: the single wheel for every platform, with sources of + # every kind, the manifest, the five DLLs, no other binary, and the version that + # is being released. + - name: '🔍 Check distribution content' + run: | + # One wheel for every platform: pure Python, tagged py3-none-any + wheels=(dist/*.whl) + test "${#wheels[@]}" = 1 || { echo "expected one wheel: ${wheels[*]}"; exit 1; } + case "${wheels[0]}" in *-py3-none-any.whl) ;; *) echo "${wheels[0]} is not py3-none-any"; exit 1;; esac + unzip -Z1 "${wheels[0]}" > wheel.txt + # Every C source and every HDL source, the manifest and the Python modules + for file in $(git ls-files src/vunit_python_bridge | grep -E '/(native|hdl/src)/|\.py$|\.toml$' | sed 's#^src/##'); do + grep -qx "$file" wheel.txt || { echo "missing $file in the wheel"; exit 1; } + done + # The five DLLs and no other binary + dlls='^vunit_python_bridge/bin/vunit_python_bridge-cp31[0-4]-win_amd64\.dll$' + test "$(grep -c "$dlls" wheel.txt)" = 5 + test "$(grep -c '\.dll$' wheel.txt)" = 5 + if grep -qE '\.(so|dylib|pyd)$' wheel.txt; then echo "the wheel has a binary"; exit 1; fi + test -f "dist/vunit_python_bridge-${{ needs.version.outputs.version }}-py3-none-any.whl" + + - name: '📤 Upload the distribution' + uses: actions/upload-artifact@v4 + with: + name: distribution + path: dist/* + if-no-files-found: error + +# +# The built wheel, installed and run like a user would on Linux, macOS and Windows +# + + smoke: + needs: build + uses: ./.github/workflows/wheel_tests.yml + with: + artifact: distribution + +# +# Publication, only of a tag +# + + publish: + needs: [version, smoke] + if: github.event_name == 'push' + runs-on: ubuntu-latest + name: '🚀 Publish to PyPI' + environment: pypi + permissions: + id-token: write + steps: + + - name: '📥 Download the distribution' + uses: actions/download-artifact@v4 + with: + name: distribution + path: dist + + # Trusted publishing: PyPI knows this repository and this workflow, so there is + # no API token to keep. + - name: '🚀 Publish' + uses: pypa/gh-action-pypi-publish@release/v1 + + release: + needs: [version, publish] + if: github.event_name == 'push' + runs-on: ubuntu-latest + name: '🏷️ GitHub release' + permissions: + contents: write + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: '📥 Download the distribution' + uses: actions/download-artifact@v4 + with: + name: distribution + path: dist + + # What was written when the tag was made is what the release says. + - name: '🏷️ Create the release' + env: + GH_TOKEN: ${{ github.token }} + run: | + git tag -l --format='%(contents)' "$GITHUB_REF_NAME" > notes.md + if [ -s notes.md ]; then + notes=(--notes-file notes.md) + else + notes=(--generate-notes) + fi + gh release create "$GITHUB_REF_NAME" dist/* \ + --title "vunit-python-bridge ${{ needs.version.outputs.version }}" \ + "${notes[@]}" diff --git a/.github/workflows/wheel_tests.yml b/.github/workflows/wheel_tests.yml new file mode 100644 index 0000000..9c0fbef --- /dev/null +++ b/.github/workflows/wheel_tests.yml @@ -0,0 +1,110 @@ +# 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 + +name: 'wheel tests' + +# The one wheel of the package, installed like a user would on every kind of +# system it supports, and tested from outside the checkout so the package under +# test can only be the installed wheel. Linux and macOS compile the bridge on +# first use. Windows gets nothing but Python and the simulator: the bridge must +# be the prebuilt DLL of the wheel for its Python, which is why every supported +# Python is tested there. Called by python_bridge.yml and the release workflow +# with the name of the artifact holding the distribution. + +on: + workflow_call: + inputs: + artifact: + type: string + required: true + +defaults: + run: + shell: bash + +jobs: + + wheel: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, py: '3.10', sim: nvc } + - { os: ubuntu-24.04, py: '3.14', sim: ghdl } + - { os: macos-15, py: '3.14', sim: nvc } + - { os: windows-latest, py: '3.10', sim: nvc } + - { os: windows-latest, py: '3.11', sim: nvc } + - { os: windows-latest, py: '3.12', sim: nvc } + - { os: windows-latest, py: '3.13', sim: nvc } + - { os: windows-latest, py: '3.14', sim: nvc } + - { os: windows-latest, py: '3.14', sim: ghdl } + name: '🎡 Wheel · ${{ matrix.os }} · Python ${{ matrix.py }} · ${{ matrix.sim }}' + env: + VUNIT_SIMULATOR: ${{ matrix.sim }} + steps: + + - name: '🧰 Checkout' + uses: actions/checkout@v4 + + - name: '📥 Download the distribution' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: dist + + - name: '🐍 Setup Python' + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.py }} + + - name: '⚙️ Setup NVC' + if: matrix.sim == 'nvc' && runner.os == 'Linux' + run: | + curl -fsSL -o nvc.deb "https://github.com/nickg/nvc/releases/download/r1.22.1/nvc_1.22.1-1_amd64_ubuntu-24.04.deb" + sudo apt-get install -y ./nvc.deb + + - name: '⚙️ Setup NVC with Homebrew' + if: matrix.sim == 'nvc' && runner.os == 'macOS' + run: brew install nvc + + - name: '⚙️ Setup NVC' + if: matrix.sim == 'nvc' && runner.os == 'Windows' + 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 + + - name: '🐍 Install the wheel in a virtual environment' + run: | + python -m venv "$RUNNER_TEMP/venv" + scripts="$RUNNER_TEMP/venv/$([ "$RUNNER_OS" = Windows ] && echo Scripts || echo bin)" + echo "$scripts" >> "$GITHUB_PATH" + "$scripts/pip" install "vunit_hdl @ git+https://github.com/VUnit/vunit.git@master" --progress-bar off + "$scripts/pip" install dist/*.whl --progress-bar off + + - name: '🚧 Feature tests against the installed wheel' + run: | + cp -r tests "$RUNNER_TEMP/tests" + cd "$RUNNER_TEMP" + python -c 'import vunit_python_bridge; assert "site-packages" in vunit_python_bridge.__file__, vunit_python_bridge.__file__; print(vunit_python_bridge.__file__)' + python tests/run.py -p 4 -o "$RUNNER_TEMP/out" + + # The bridge was the DLL of the wheel, copied to the output path, and nothing was compiled + - name: '🔍 The prebuilt DLL was used' + if: runner.os == 'Windows' + run: | + if command -v cl; then echo "MSVC is on PATH"; exit 1; fi + find "$RUNNER_TEMP/out" -path '*-win_amd64-*' -name vunit_python_bridge.dll | grep -v -- '-win_amd64-gcc-' | grep . + if find "$RUNNER_TEMP/out" -path '*-win_amd64-gcc-*' | grep .; then echo "the bridge was compiled"; exit 1; fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..934b977 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +/build/ +/dist/ +/docs/_build/ +/src/vunit_python_bridge/bin/ +vunit_out/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..de4404e --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# vunit-python-bridge + +A VUnit package making Python callable from VHDL. + +## Overview + +vunit-python-bridge embeds a Python interpreter in the simulator process so that a VHDL testbench +can execute Python code and call Python functions — a NumPy reference model, a constraint solver, a +plot of what the design just produced — without leaving the simulation. The VHDL API, +`python_pkg`/`python_context`, is compiled into the `python_bridge` library and is implemented for +the selected simulator by a foreign language interface the package builds itself: a small C library +called through VHPIDIRECT (NVC, GHDL) or the FLI (Questa/ModelSim), or a VHPI application built with +the simulator's own compiler driver (Riviera-PRO/Active-HDL). Values cross the interface with their +VHDL types: `integer`, `real`, `string`, `boolean`, `std_ulogic`, `unsigned`/`signed`, the vector +types, and `integer_array_t` as a NumPy array. + +## Installation + +```bash +pip install vunit-python-bridge +``` + +## Basic Example + +The run script adds the package after the VUnit builtins: + +```python +from vunit import VUnit + +vu = VUnit.from_argv() +vu.add_vhdl_builtins() +vu.add_package("vunit-python-bridge", allow_setup=True) + +lib = vu.add_library("lib") +lib.add_source_files("*.vhd") + +vu.main() +``` + +`allow_setup=True` is required because, when added, the package builds its native library and +registers simulator options as part of its setup function, which VUnit only runs when the project +allows it. + +The testbench gets the API from the `python_context` context of the `python_bridge` library: + +```vhdl +library vunit_lib; +context vunit_lib.vunit_context; + +library python_bridge; +context python_bridge.python_context; + +... + +exec("import numpy as np"); +exec("def gain(x): return [2 * v for v in x]"); + +check_equal(eval_integer("int(np.sum([1, 2, 3]))"), 6); +check_equal(eval_string("'-'.join(['a', 'b'])"), string'("a-b")); +check(call_integer_vector("gain", arg(integer_vector'(1, 2))) = integer_vector'(2, 4)); +``` + +## Supported Simulators + +| Simulator | Interface | Status | +| --- | --- | --- | +| NVC | VHPIDIRECT | Tested on Linux, macOS and Windows | +| GHDL (mcode, llvm-jit, llvm, gcc) | VHPIDIRECT | Tested on Linux, macOS and Windows | +| Questa/ModelSim | FLI | Tested manually on Linux; the Windows build is untested | +| Riviera-PRO, Active-HDL | VHPI | Untested; a subset of the API, see the documentation | + +## Requirements + +- `vunit_hdl >= 5.0.0.dev12`, the version carrying the support for packages and simulator hooks + ([VUnit/vunit#1221](https://github.com/VUnit/vunit/pull/1221)). Until that is part of a release, + install VUnit from its master branch: + `pip install git+https://github.com/VUnit/vunit.git@master` +- VHDL-2008 or later. +- CPython 3.10 or later, standard (GIL) build, with a shared `libpython` (`--enable-shared`), which + is what distribution Pythons, `actions/setup-python`, `uv` and `pyenv` provide by default. +- Linux and macOS: a C compiler (`cc`, `gcc` or `clang`, or `CC`) and the Python development headers + (for example the `python3-dev` package). The bridge library is compiled on first use and cached + under the VUnit output path. +- Windows: a 64-bit CPython from python.org (or compatible). The package ships prebuilt DLLs + for NVC and GHDL. Questa builds its FLI library, and NVC and GHDL their library when the DLLs + are missing, with a MinGW-w64 gcc: `CC`, the one bundled with the simulator, or `gcc` on `PATH`. + +The simulator runs Python in the same environment as VUnit itself, including an active virtual +environment and its installed packages. + +## Documentation + +The user guide is in [docs/user_guide.rst](docs/user_guide.rst): sessions, `exec`, `eval`, `call` +and its argument forms, `exec_file`, `import_run_script`, the type mapping, `integer_array_t` and +NumPy, error reporting, and how the bridge works. A complete example covering all three simulator +families is in [examples/embedded_python](examples/embedded_python). + +## License + +Mozilla Public License, v. 2.0, like VUnit. See [LICENSE](LICENSE). diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..c88aa1d --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,20 @@ +# 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 + +""" +Sphinx configuration of the vunit-python-bridge documentation. + +Build with: sphinx-build -b html docs docs/_build +""" + +project = "vunit-python-bridge" +copyright = "2014-2026, Lars Asplund" # pylint: disable=redefined-builtin +author = "Lars Asplund" +release = "0.1.0" + +extensions = [] +exclude_patterns = ["_build"] +html_theme = "alabaster" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..dea8845 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,9 @@ +vunit-python-bridge +=================== + +A VUnit package making Python callable from VHDL. + +.. toctree:: + :maxdepth: 2 + + user_guide diff --git a/docs/user_guide.rst b/docs/user_guide.rst new file mode 100644 index 0000000..47d731d --- /dev/null +++ b/docs/user_guide.rst @@ -0,0 +1,736 @@ +.. _python_bridge: + +Calling Python from VHDL +======================== + +``vunit-python-bridge`` is a VUnit package that embeds 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 compiled into the ``python_bridge`` +library and is added to a project with ``add_package()``: + +.. code-block:: python + + from vunit import VUnit + + vu = VUnit.from_argv() + vu.add_vhdl_builtins() + vu.add_package("vunit-python-bridge", allow_setup=True) + + lib = vu.add_library("lib") + lib.add_source_files("*.vhd") + + vu.main() + +``allow_setup=True`` is required because, when added, the package builds its native library and +registers simulator options as part of its setup function, which VUnit only runs when the project +allows it. + +``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; + + library python_bridge; + context python_bridge.python_context; + + ... + + exec("import numpy as np"); + result := eval("int(np.sum([1, 2, 3]))"); + +Requirements +------------ + +* A VUnit with support for packages and simulator hooks, see the README. +* NVC, GHDL or Questa/ModelSim, through the VUnit Python bridge described + below, or Riviera-PRO/Active-HDL (VHPI) through the VHPI application, which + the package builds in the same way, see + :ref:`python_bridge:other_simulators`. Any other simulator 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 + when the 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 must be a 64-bit (x64) + build too, since the Python DLL is loaded into its process; the package + 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. + +A missing prerequisite, for example a missing C compiler or missing Python +headers, is reported when the package is added: ``add_package`` raises an error +explaining what to install. + +.. _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 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 + + 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("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. + +A session is a VUnit object with an `identity +`__ of its own, returned by +``get_id(session)``. ``new_session(name)`` creates the identity ``name`` under +``python_bridge: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: + +.. 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 + + 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. + +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_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_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_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 +procedure form, since a function cannot know the width of the result. + +.. code-block:: vhdl + + variable byte : std_ulogic_vector(7 downto 0); + variable level : signed(15 downto 0); + begin + eval_std_ulogic_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_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 +========================== ======================================================== + +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_ulogic``, ``unsigned`` or ``signed`` value; any other metavalue is an +error. + +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 - python_bridge:python - arg cannot convert 'X'; expected '0', '1', 'L' or 'H' + FAILURE - python_bridge: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' + +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, +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_ulogic``, ``call_std_ulogic_vector``, +``call_integer_array``, ``call_string``, ``call_real_vector`` and +``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_ulogic_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", 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. + +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: + +.. code-block:: vhdl + + -- Calls plot(data, **dict(title="Step response", grid=True)) + call("plot", arg(data), kwarg("title", string'("Step response")) & kwarg("grid", true)); + + -- 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``. + +``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 +------------------ + +``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 ``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(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 +``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_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 + :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 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 ``python_bridge:python:default`` and +``python_logger`` (named ``python_bridge: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 - python_bridge:python:default - 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 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: + +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 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. + * ``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 `src/vunit_python_bridge/native/vhpi +`__ +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``. The package builds the application under the +output path (``//libraries``) the first time it is +added and rebuilds it when the sources, the Python running VUnit or the +simulator installation change, so a run script needs nothing beyond +``add_package("vunit-python-bridge", allow_setup=True)``. + +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_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 the logger of the session, and ``real`` values +outside the single precision float range are rejected. + +The ``embedded_python`` example of this repository, +`examples/embedded_python +`__, +is 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 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 +``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. + +.. _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 (`src/vunit_python_bridge/native +`__). +NVC and GHDL call it through VHPIDIRECT; Questa/ModelSim calls it through the +FLI, using the front end in `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 the package is added, 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. The + package ships no prebuilt Linux library. + +Windows + For NVC and GHDL, the package ships DLLs built with MSVC for each supported + Python minor version (``src/vunit_python_bridge/bin``). The matching DLL is + copied to the output path and nothing is compiled. A development checkout of + the package does not contain the DLLs; they can be built with + ``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 the + package 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 +requires the ``PyConfig`` initialization API, which is not part of the +limited API. + +The package registers simulator hooks that make 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, which the hook recognizes +from the ``backend`` of the GHDL interface it is called with. The FLI +attributes generated for Questa name the library by absolute path, and the +``vsim`` process VUnit starts is 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. That option is set up when the process +starts, so it has to go on the command line of the process rather than on the +``vsim`` command of the do-file VUnit generates, and it is only passed to a +``vsim`` listing it in ``vsim -help all``. 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. The released wheel is also installed and tested on Linux, macOS and on +Windows machines without a compiler, there with every supported Python, so +each of its prebuilt DLLs is run. + +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. diff --git a/examples/embedded_python/bump.py b/examples/embedded_python/bump.py new file mode 100644 index 0000000..af3739e --- /dev/null +++ b/examples/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/embedded_python/device_model.py b/examples/embedded_python/device_model.py new file mode 100644 index 0000000..d7729ff --- /dev/null +++ b/examples/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_ulogic characters, for call_std_ulogic_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_ulogic character, for call_std_ulogic. + """ + 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/embedded_python/fixed_point_model.py b/examples/embedded_python/fixed_point_model.py new file mode 100644 index 0000000..2564504 --- /dev/null +++ b/examples/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/embedded_python/golden_model.py b/examples/embedded_python/golden_model.py new file mode 100644 index 0000000..afbbcfd --- /dev/null +++ b/examples/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/embedded_python/image_model.py b/examples/embedded_python/image_model.py new file mode 100644 index 0000000..70913f9 --- /dev/null +++ b/examples/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/embedded_python/python_model.py b/examples/embedded_python/python_model.py new file mode 100644 index 0000000..5ff6059 --- /dev/null +++ b/examples/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/embedded_python/python_model.vhd b/examples/embedded_python/python_model.vhd new file mode 100644 index 0000000..dff00c1 --- /dev/null +++ b/examples/embedded_python/python_model.vhd @@ -0,0 +1,36 @@ +-- 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; + +library python_bridge; +context python_bridge.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/embedded_python/run.py b/examples/embedded_python/run.py new file mode 100644 index 0000000..1042d6d --- /dev/null +++ b/examples/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 the vunit-python-bridge package: 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 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 +component whose behaviour is a Python function. Some tests need Python +packages the bridge 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 the +user guide of vunit-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_package("vunit-python-bridge", allow_setup=True) + vu.add_random() + # The setup function of the package 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 the bridge 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/embedded_python/tb_example.vhd b/examples/embedded_python/tb_example.vhd new file mode 100644 index 0000000..df63c80 --- /dev/null +++ b/examples/embedded_python/tb_example.vhd @@ -0,0 +1,775 @@ +-- 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; +use vunit_lib.random_pkg.all; + +library python_bridge; +context python_bridge.python_context; + +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 := 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); + 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(join(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_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); + 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. 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); + + -- 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(join(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", 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 + 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 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 + -- 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_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; + + -- 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 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(default_logger, failure); + table := call_integer_vector_ptr("sine_table", arg(0)); + check_only_log( + 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 + -- 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(default_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 Python files in the directory tree + -- rooted in the testbench directory + exec("from glob import glob"); + exec("python_files = glob('" & join(tb_path(runner_cfg), "**", "*.py") & "', recursive=True)"); + + 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 + info(replace(eval("python_files[" & to_string(idx) & "]"), "\", "/")); + 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')"), "\", "/")); + 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(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); + 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 => join(tb_path(runner_cfg), "python_model.py")) + port map(x => model_x, y => model_y); +end; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f3c2336 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "vunit-python-bridge" +version = "0.1.0" +description = "A VUnit package making Python callable from VHDL." +urls = {homepage = "https://github.com/VUnit/vunit-python-bridge"} +readme = "README.md" +license = "MPL-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [ + {name = "Lars Asplund", email = "lars.anders.asplund@gmail.com"} +] +keywords = ["vunit", "vunitpkg", "verification", "vhdl", "fpga", "asic", "python", "vhpi", "vhpidirect", "fli"] +classifiers = [ + "Development Status :: 4 - Beta", + "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", + "Natural Language :: English", + "Intended Audience :: Developers", +] +dependencies = [ + "numpy", + # The VUnit adding packages and simulator hooks, VUnit/vunit#1221. Until that is + # part of a release: pip install git+https://github.com/VUnit/vunit.git@master + "vunit_hdl>=5.0.0.dev12", +] + +[tool.setuptools] +# Don't include files in the wheel unless specified in [tool.setuptools.package-data]. +include-package-data = false + +[tool.setuptools.packages.find] +# Search for Python packages (directories containing __init__.py) starting at this point. +where = ["src"] + +# Only include the Python package named "vunit_python_bridge". +# This prevents accidentally packaging unrelated directories. +include = ["vunit_python_bridge"] + +[tool.setuptools.package-data] +# Declare non-Python files that should be included inside the vunit_python_bridge +# Python package in the wheel. All paths are relative to the +# vunit_python_bridge/ package directory. +vunit_python_bridge = [ + "vunit_pkg.toml", + "hdl/src/*.vhd", + "hdl/src/*.vhd.in", + "hdl/tools/*.vhd.in", + "hdl/tools/*.py", + "native/*.c", + "native/*.h", + "native/vhpi/*.c", + "native/vhpi/*.h", + "bin/*.dll", +] + +[tool.black] +line-length = 120 + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/src/vunit_python_bridge/__init__.py b/src/vunit_python_bridge/__init__.py new file mode 100644 index 0000000..1bec107 --- /dev/null +++ b/src/vunit_python_bridge/__init__.py @@ -0,0 +1,92 @@ +# 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 VUnit package making Python callable from VHDL. + +``vu.add_package("vunit-python-bridge", allow_setup=True)`` compiles +``python_pkg``/``python_context`` into the ``python_bridge`` library and sets up the foreign +language interface implementing them for the selected simulator: + +* NVC, GHDL (VHPIDIRECT) and Questa/ModelSim (FLI) are served by the VUnit Python bridge, a + small C library (native/*.c) embedding CPython in the simulator process. +* Riviera-PRO/Active-HDL are served by a VHPI application built with the simulator's own + compiler driver. + +Modules: + +* bridge: setup of the bridge, its configuration and its generated VHDL. +* native_library: compiles and caches the library on Linux and macOS, selects the prebuilt + DLL on Windows. +* foreign_application: builds the VHPI application. +* simulator_hooks: makes NVC and GHDL find the library. +* runtime: runs inside the simulator's embedded interpreter. +""" + +from pathlib import Path + +VHDL_PATH = Path(__file__).parent.resolve() / "hdl" / "src" + +# The foreign language interface implementing the package for a simulator. VUnit says which +# simulator was selected, the package says how it is served: NVC and GHDL both go through +# VHPIDIRECT but bind the library differently, Questa/ModelSim goes through the FLI and +# Riviera-PRO/Active-HDL through a VHPI application of their own. +FOREIGN_LANGUAGE_INTERFACES = { + "nvc": "VHPIDIRECT_NVC", + "ghdl": "VHPIDIRECT_GHDL", + "modelsim": "FLI", + "rivierapro": "VHPI", + "activehdl": "VHPI", +} + +# The simulators of the table, named as a user knows them +SUPPORTED_SIMULATORS = "NVC, GHDL, Questa/ModelSim, Riviera-PRO or Active-HDL" + + +def setup(context): + """ + Complete the addition of the package. + + ``vunit_pkg.toml`` lists the sources both variants of the package share, this adds the + foreign language interface of the selected simulator: the VHPI application for + Riviera-PRO/Active-HDL, the VUnit Python bridge for everyone else. + """ + # pylint: disable=import-outside-toplevel + from .bridge import setup as setup_bridge + from .foreign_application import setup_vhpi_application + from . import simulator_hooks + + simulator_name = context.simulator_name + if simulator_name is None: + raise RuntimeError( + f"The vunit-python-bridge package requires {SUPPORTED_SIMULATORS} but no simulator was found" + ) + + interface = FOREIGN_LANGUAGE_INTERFACES.get(simulator_name) + if interface is None: + raise RuntimeError( + f"The vunit-python-bridge package requires {SUPPORTED_SIMULATORS}, " + f"it has no foreign language interface for {simulator_name}" + ) + + 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"]) + setup_vhpi_application(context.output_path, simulator_name, context.simulator_prefix) + return + + bridge = setup_bridge( + context.output_path, + context.run_script_path, + simulator_name, + context.simulator_prefix, + context.simulator_backend, + ) + + context.add_source_files(context.library.name, list(bridge.vhdl_files)) + + simulator_hooks.register(context, bridge) diff --git a/src/vunit_python_bridge/bridge.py b/src/vunit_python_bridge/bridge.py new file mode 100644 index 0000000..e9b07af --- /dev/null +++ b/src/vunit_python_bridge/bridge.py @@ -0,0 +1,171 @@ +# 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 List, NamedTuple, Optional + +from .native_library import ( + PACKAGE_PATH, + PythonBridgeError, + check_python_build, + check_windows_64bit_simulator, + prepare_library, + windows_python_dll, +) + +RUNTIME_SOURCE = PACKAGE_PATH / "runtime.py" +VHDL_SOURCE_PATH = PACKAGE_PATH / "hdl" / "src" +BRIDGE_PACKAGE_TEMPLATE = VHDL_SOURCE_PATH / "python_bridge_pkg.vhd.in" +CONFIG_FILE_NAME = "vunit_python_bridge.cfg" + +# The simulators the bridge serves, reaching it through VHPIDIRECT and through the FLI. +# The rest of the simulators of the package are served by the VHPI application. +FLI_SIMULATORS = ("modelsim",) +BRIDGE_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+)\}") +# The subprogram declarations of the template, whose bodies are generated +SUBPROGRAM_PATTERN = re.compile(r"^ (impure function|procedure) (\w+)(\([^)]*\))?( return \w+)?;", re.MULTILINE) + + +class PythonBridge(NamedTuple): + """ + A prepared bridge: the native library and the generated VHDL. + """ + + library_file: Path + vhdl_files: List[Path] + + +def setup( + output_path, + run_script_path: Optional[Path], + simulator_name: Optional[str] = None, + simulator_prefix: Optional[str] = None, + simulator_backend: Optional[str] = None, +) -> PythonBridge: + """ + Prepare the Python bridge for a project. Called by the package setup function. + + :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. + :param simulator_name: The name of the selected simulator, None for no simulator. + :param simulator_prefix: The path its executables were found in. + :param simulator_backend: How the installation found there was built, which for GHDL + is the code generator deciding how the library is bound. + :returns: The bridge. Its vhdl_files are to be added to the package library. + """ + if simulator_name is not None and simulator_name not in BRIDGE_SIMULATORS: + raise PythonBridgeError( + f"The vunit-python-bridge package requires NVC, GHDL or Questa/ModelSim, " + f"it is not supported for {simulator_name}" + ) + + check_python_build() + check_windows_64bit_simulator(simulator_name, simulator_prefix) + + is_fli = simulator_name in FLI_SIMULATORS + if is_fli and simulator_prefix is None: + raise PythonBridgeError( + f"The FLI variant of the bridge is built against the {simulator_name} installation, but it was not found" + ) + + root = Path(output_path) / "python_bridge" + library_file = prepare_library(root, Path(simulator_prefix) if is_fli else None) + 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 + # 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_backend, library_file)} {{entry_point}}" + ) + bridge_package = root / "vhdl" / "python_bridge_pkg.vhd" + _write_if_changed(bridge_package, _render_bridge_package(foreign)) + + return PythonBridge( + library_file, + [ + bridge_package, + VHDL_SOURCE_PATH / "python_ffi_pkg_bridge.vhd", + ], + ) + + +def _vhpidirect_token(simulator_name, simulator_backend: Optional[str], 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 simulator_backend in GHDL_LINKING_BACKENDS: + return "-lvunit_python_bridge" + return library_file.name + + +def _render_bridge_package(foreign: str) -> str: + """ + 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") + 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 _config_text(run_script_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), + "run_script_dir": run_script_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) diff --git a/src/vunit_python_bridge/foreign_application.py b/src/vunit_python_bridge/foreign_application.py new file mode 100644 index 0000000..0d401b9 --- /dev/null +++ b/src/vunit_python_bridge/foreign_application.py @@ -0,0 +1,90 @@ +# 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`` +for the simulators the VUnit Python bridge does not serve. + +``setup_vhpi_application`` is called by the setup function of the package 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 os +import sys +import hashlib + +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" + + +def setup_vhpi_application(output_path, simulator_name, simulator_prefix): + """ + 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. + + :param simulator_prefix: The path the executables of the simulator were found in, which is + where its ccomp compiler driver is. + """ + if simulator_prefix is None: + raise RuntimeError(f"The VHPI application is built with the ccomp of {simulator_name}, but it was not found") + + check_windows_64bit_simulator(simulator_name, simulator_prefix) + target = Path(output_path) / simulator_name / "libraries" / "python.dll" + sources = [SRC_PATH / "python_pkg_vhpi.c", SRC_PATH / "python_pkg.c"] + simulator_prefix = Path(simulator_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): + """ + 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_vhpi(target, sources, simulator_prefix): + """ + Compile the VHPI application with the ccomp compiler driver of Riviera-PRO/Active-HDL. + """ + python_include, python_libs = python_dev_paths() + ccomp = simulator_prefix / ("ccomp.exe" if sys.platform == "win32" else "ccomp") + 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", 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/src/vunit_python_bridge/hdl/src/python_bridge_pkg.vhd.in b/src/vunit_python_bridge/hdl/src/python_bridge_pkg.vhd.in new file mode 100644 index 0000000..b8aa051 --- /dev/null +++ b/src/vunit_python_bridge/hdl/src/python_bridge_pkg.vhd.in @@ -0,0 +1,91 @@ +-- 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 (src/vunit_python_bridge/native). +-- +-- This file is a template. The setup function of the package 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 adds the +-- result to the python_bridge library, 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 +-- 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_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}"; + + 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; + diff --git a/src/vunit_python_bridge/hdl/src/python_context.vhd b/src/vunit_python_bridge/hdl/src/python_context.vhd new file mode 100644 index 0000000..c70bb14 --- /dev/null +++ b/src/vunit_python_bridge/hdl/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 python_bridge; + use python_bridge.python_pkg.all; + use python_bridge.python_ffi_pkg.all; +end context; diff --git a/src/vunit_python_bridge/hdl/src/python_ffi_pkg_bridge.vhd b/src/vunit_python_bridge/hdl/src/python_ffi_pkg_bridge.vhd new file mode 100644 index 0000000..3b2e491 --- /dev/null +++ b/src/vunit_python_bridge/hdl/src/python_ffi_pkg_bridge.vhd @@ -0,0 +1,507 @@ +-- 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 (src/vunit_python_bridge). It provides +-- the same subprograms as the FLI and VHPI variants but over the private +-- python_bridge_pkg, which the setup function of the package generates. +-- +-- Deviations from the other variants: +-- * The subprograms are impure since they call impure foreign subprograms. +-- * 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 +-- 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. + +library vunit_lib; +use vunit_lib.id_pkg.all; +use vunit_lib.integer_array_pkg.all; +use vunit_lib.integer_vector_ptr_pkg.all; +use vunit_lib.logger_pkg.all; +use work.python_bridge_pkg.all; + +package python_ffi_pkg is + -- 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("python_bridge:python"); + + -- 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 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"); + -- + -- 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. + -- 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. + 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 src/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_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; + constant p_kind_integer_vector : integer := 9; + constant p_kind_real_vector : integer := 10; + + -- 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( + 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; + + -- 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; 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; + + -- 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_ulogic_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 + ----------------------------------------------------------------------------- + -- Sessions + ----------------------------------------------------------------------------- + impure function new_session(name : string) return python_session_t is + begin + return new_session(get_id(name, parent => p_python_id)); + end; + + 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(get_id(session)); + end; + + impure function p_is_default(session : python_session_t) return boolean is + begin + return get_id(session) = get_id(default_session); + end; + + ----------------------------------------------------------------------------- + -- Operation names + ----------------------------------------------------------------------------- + impure function p_exec_operation(session : python_session_t := default_session) return string is + begin + if p_is_default(session) then + return "exec"; + end if; + return "exec(session => """ & name(session) & """)"; + 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 + return "eval(""" & expr & """)"; + end if; + return "eval(""" & expr & """, session => """ & name(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; logger : logger_t := python_logger + ) return boolean is + begin + if status = 0 then + return true; + end if; + failure(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; + + -- 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 := get_logger(get_id(session)); + begin + return p_succeeded(p_send(full_name(get_id(session))), operation, logger) + and p_succeeded(vpy_begin, operation, logger); + end; + + impure function p_exec( + text : string; + is_file : integer; + operation : string; + session : python_session_t := default_session + ) return boolean is + constant logger : logger_t := get_logger(get_id(session)); + begin + return p_begin(session, operation) + and p_succeeded(p_send(text), operation, logger) + and p_succeeded(vpy_execute(is_file), operation, logger); + 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 + constant logger : logger_t := get_logger(get_id(session)); + begin + return p_begin(session, operation) + and p_succeeded(p_send(expr), operation, logger) + and p_succeeded(vpy_eval(kind, width), operation, logger); + 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/src/vunit_python_bridge/hdl/src/python_pkg.vhd b/src/vunit_python_bridge/hdl/src/python_pkg.vhd new file mode 100644 index 0000000..c238622 --- /dev/null +++ b/src/vunit_python_bridge/hdl/src/python_pkg.vhd @@ -0,0 +1,1087 @@ +-- 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; +library vunit_lib; +use vunit_lib.path.all; +use vunit_lib.run_pkg.all; +use vunit_lib.runner_pkg.all; +use vunit_lib.integer_vector_ptr_pkg.all; +use vunit_lib.string_ops.all; + +use std.textio.all; + +------------------------------------------------------------------------------ +-- This file is generated from tools/python_pkg.vhd.in by +-- src/vunit_python_bridge/hdl/tools/generate_python_pkg.py. Do not edit. +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +use vunit_lib.integer_array_pkg.all; +use vunit_lib.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]; + + type arg_t is record + name : string; + value : string; + 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; + 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; + + -- 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; + + -- 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 + ) 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]; + + ----------------------------------------------------------------------------- + -- Argument groups + ----------------------------------------------------------------------------- + -- 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)). A group of positional arguments becomes + -- *(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; + + ----------------------------------------------------------------------------- + -- 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. + + ----------------------------------------------------------------------------- + -- Results of eval: boolean, std_ulogic, vectors and arrays + ----------------------------------------------------------------------------- + -- 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 + -- 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_ulogic( + expr : string; session : python_session_t := default_session + ) return std_ulogic; + alias eval is eval_std_ulogic[string, python_session_t return std_ulogic]; + + impure function eval_std_ulogic_vector( + expr : string; session : python_session_t := default_session + ) return std_ulogic_vector; + + impure function eval_integer_array( + expr : string; session : python_session_t := default_session + ) return integer_array_t; + + 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 + ); + procedure eval_unsigned( + expr : string; result : out unsigned; session : python_session_t := default_session + ); + + ----------------------------------------------------------------------------- + -- 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; + 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_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_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_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_ulogic_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_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 + ); + 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 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; + +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 + 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 + 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 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; + + -- 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. + 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) return string is + begin + return to_py_list_str(value); + end; + + impure function p_arg_value(value : std_ulogic; operation : string) return string is + begin + if to_x01(value) = '1' then + return "True"; + elsif to_x01(value) = '0' then + return "False"; + end if; + return p_failed_value( + operation & " cannot convert " & std_ulogic'image(value) & "; expected '0', '1', 'L' or 'H'" + ); + 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 + constant hex_digits : string(1 to 16) := "0123456789ABCDEF"; + constant padding : natural := (4 - bits'length mod 4) mod 4; + variable padded : std_ulogic_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_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 is_x(std_ulogic_vector(value)) then + 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; + + 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 is_x(std_ulogic_vector(value)) then + return p_failed_value(p_metavalue_error(std_ulogic_vector(value), operation)); + end if; + if value'length = 0 then + return "0x0"; + end if; + + folded := signed(to_x01(std_ulogic_vector(value))); + if folded(folded'left) = '0' then + 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_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 + -- 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 + begin + return (p_positional_arg, p_arg_value(value)); + end; + + function kwarg(kw : string; value : real_vector) return arg_t is + begin + return (kw, p_arg_value(value)); + end; + + impure function arg(value : integer_vector_ptr_t) return arg_t is + begin + return (p_positional_arg, p_arg_value(value)); + end; + + impure function kwarg(kw : string; value : integer_vector_ptr_t) return arg_t is + begin + return (kw, p_arg_value(value)); + end; + + impure function arg(value : std_ulogic) return arg_t is + begin + return (p_positional_arg, p_arg_value(value, "arg")); + end; + + impure function kwarg(kw : string; value : std_ulogic) return arg_t is + begin + return (kw, p_arg_value(value, "kwarg")); + end; + + impure function arg_unsigned(value : unsigned) return arg_t is + begin + return (p_positional_arg, p_arg_value(value, "arg_unsigned")); + end; + + impure function kwarg_unsigned(kw : string; value : unsigned) return arg_t is + begin + return (kw, p_arg_value(value, "kwarg_unsigned")); + end; + + impure function arg_signed(value : signed) return arg_t is + begin + return (p_positional_arg, p_arg_value(value, "arg_signed")); + end; + + impure function kwarg_signed(kw : string; value : signed) return arg_t is + begin + return (kw, p_arg_value(value, "kwarg_signed")); + end; + + impure function arg(value : integer_array_t) return arg_t is + begin + return (p_positional_arg, p_arg_value(value, "arg")); + end; + + impure function kwarg(kw : string; value : integer_array_t) return arg_t is + begin + 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 + 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 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); + 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; + + 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; + + ----------------------------------------------------------------------------- + -- Operations implemented by the Python bridge (NVC, GHDL and Questa) + ----------------------------------------------------------------------------- + -- Python represents std_ulogic values by these characters + constant p_std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; + + function p_to_std_ulogic(value : character) return std_ulogic is + begin + 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 + return 'X'; + end; + + -- The elements of the characters, the first character becoming the leftmost element + 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_ulogic_vector(value'length - 1 downto 0); + begin + for idx in normalized'range loop + result(value'length - idx) := p_to_std_ulogic(normalized(idx)); + end loop; + return result; + end; + + ----------------------------------------------------------------------------- + -- 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(p_file_path(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_ulogic( + expr : string; session : python_session_t := default_session + ) return std_ulogic is + begin + 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_ulogic_vector( + expr : string; session : python_session_t := default_session + ) return std_ulogic_vector is + begin + 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; + + 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_ulogic_vector( + expr : string; result : out std_ulogic_vector; session : python_session_t := default_session + ) is + begin + 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; + + 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_ulogic_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_ulogic_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( + 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( + 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( + 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( + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + 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_ulogic is + begin + return eval_std_ulogic( + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + 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_ulogic_vector is + begin + return eval_std_ulogic_vector( + 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( + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), session + ); + end; + + 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_ulogic_vector( + 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( + 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( + to_call_str(identifier, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10), result, session + ); + end; +end package body; diff --git a/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd b/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd new file mode 100644 index 0000000..f8274a1 --- /dev/null +++ b/src/vunit_python_bridge/hdl/src/python_pkg_vhpi.vhd @@ -0,0 +1,270 @@ +-- 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; + +library vunit_lib; +use vunit_lib.id_pkg.all; +use vunit_lib.integer_array_pkg.all; +use vunit_lib.integer_vector_ptr_pkg.all; +use vunit_lib.logger_pkg.all; + +package python_ffi_pkg is + -- 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("python_bridge:python"); + + -- 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. + -- 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 + -- 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"; + 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"; + 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"; + 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"; + 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"; + 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); + 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 of the Python interface, the parent of the session loggers + constant python_logger : logger_t := get_logger(p_python_id); + + -- Result kinds, must match src/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_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; + constant p_kind_integer_vector : integer := 9; + constant p_kind_real_vector : integer := 10; + + -- Name of the operation, used in the error messages + 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( + 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_ulogic_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 + impure function new_session(name : string) return python_session_t is + begin + return new_session(get_id(name, parent => p_python_id)); + end; + + 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(get_id(session)); + end; + + impure function p_is_default(session : python_session_t) return boolean is + begin + return get_id(session) = get_id(default_session); + end; + + procedure p_check_session(session : python_session_t) is + begin + if not p_is_default(session) then + report "Python sessions are only supported with NVC, GHDL and Questa" severity failure; + end if; + end; + + 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; + + 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; + + impure 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; + + impure 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; + + 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); + 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; + + impure function p_eval_operation(expr : string; session : python_session_t := default_session) return string is + begin + if p_is_default(session) then + return "eval(""" & expr & """)"; + end if; + return "eval(""" & expr & """, session => """ & name(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/src/vunit_python_bridge/hdl/tools/generate_python_pkg.py b/src/vunit_python_bridge/hdl/tools/generate_python_pkg.py new file mode 100644 index 0000000..8dc3252 --- /dev/null +++ b/src/vunit_python_bridge/hdl/tools/generate_python_pkg.py @@ -0,0 +1,386 @@ +# 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 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 +# 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_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, 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. +# +# name: suffix of eval_ and call_ +# vhdl: the VHDL type +# kind: result kind passed to the bridge, see src/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_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_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_ulogic_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_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_ulogic_vector(p_result_string))", + ), + dict( + name="unsigned", + vhdl="unsigned", + kind="p_kind_unsigned", + value="unsigned(p_to_std_ulogic_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(): + """ + Declarations of the generated arg and kwarg subprograms. + """ + lines = [] + for value in ARG_VALUES: + 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(): + """ + 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, target in [ + (f"arg{suffix}", f"value : {value_type}", "p_positional_arg"), + (f"kwarg{suffix}", f"kw : string; value : {value_type}", "kw"), + ]: + 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 + begin + return ({target}, {arg_value}); + 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']}( + 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']}( + 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( + arg_declarations=arg_declarations(), + arg_functions=arg_functions(), + eval_declarations=eval_declarations(), + call_declarations=call_declarations(), + 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/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in b/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in new file mode 100644 index 0000000..f5261e0 --- /dev/null +++ b/src/vunit_python_bridge/hdl/tools/python_pkg.vhd.in @@ -0,0 +1,765 @@ +-- 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; +library vunit_lib; +use vunit_lib.path.all; +use vunit_lib.run_pkg.all; +use vunit_lib.runner_pkg.all; +use vunit_lib.integer_vector_ptr_pkg.all; +use vunit_lib.string_ops.all; + +use std.textio.all; + +------------------------------------------------------------------------------ +-- This file is generated from tools/python_pkg.vhd.in by +-- src/vunit_python_bridge/hdl/tools/generate_python_pkg.py. Do not edit. +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +use vunit_lib.integer_array_pkg.all; +use vunit_lib.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]; + + type arg_t is record + name : string; + value : string; + 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; + 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; + + -- 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 + + -- 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 + ) 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]; + + ----------------------------------------------------------------------------- + -- Argument groups + ----------------------------------------------------------------------------- + -- 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)). A group of positional arguments becomes + -- *(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; + + ----------------------------------------------------------------------------- + -- 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. + + ----------------------------------------------------------------------------- + -- Results of eval: boolean, std_ulogic, vectors and arrays + ----------------------------------------------------------------------------- + -- 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 + -- since a function cannot know the width of the result. +$eval_declarations + + ----------------------------------------------------------------------------- + -- Results of call: string, boolean, std_ulogic, vectors and arrays + ----------------------------------------------------------------------------- +$call_declarations + + ----------------------------------------------------------------------------- + -- Execution of Python files + ----------------------------------------------------------------------------- + -- Execute a Python file. A relative file name is relative to the directory + -- 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; + +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 + 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 + 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 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; + + -- 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. + 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) return string is + begin + return to_py_list_str(value); + end; + + impure function p_arg_value(value : std_ulogic; operation : string) return string is + begin + if to_x01(value) = '1' then + return "True"; + elsif to_x01(value) = '0' then + return "False"; + end if; + return p_failed_value( + operation & " cannot convert " & std_ulogic'image(value) & "; expected '0', '1', 'L' or 'H'" + ); + 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 + constant hex_digits : string(1 to 16) := "0123456789ABCDEF"; + constant padding : natural := (4 - bits'length mod 4) mod 4; + variable padded : std_ulogic_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_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 is_x(std_ulogic_vector(value)) then + 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; + + 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 is_x(std_ulogic_vector(value)) then + return p_failed_value(p_metavalue_error(std_ulogic_vector(value), operation)); + end if; + if value'length = 0 then + return "0x0"; + end if; + + folded := signed(to_x01(std_ulogic_vector(value))); + if folded(folded'left) = '0' then + 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_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 + -- 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; + +$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 + 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 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); + 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; + + 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; + + ----------------------------------------------------------------------------- + -- Operations implemented by the Python bridge (NVC, GHDL and Questa) + ----------------------------------------------------------------------------- + -- Python represents std_ulogic values by these characters + constant p_std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; + + function p_to_std_ulogic(value : character) return std_ulogic is + begin + 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 + return 'X'; + end; + + -- The elements of the characters, the first character becoming the leftmost element + 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_ulogic_vector(value'length - 1 downto 0); + begin + for idx in normalized'range loop + result(value'length - idx) := p_to_std_ulogic(normalized(idx)); + end loop; + return result; + end; + + ----------------------------------------------------------------------------- + -- 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(p_file_path(file_name), session); + end; + + ----------------------------------------------------------------------------- + -- eval + ----------------------------------------------------------------------------- +$eval_subprograms + + ----------------------------------------------------------------------------- + -- call + ----------------------------------------------------------------------------- +$call_subprograms +end package body; diff --git a/src/vunit_python_bridge/native/arguments.c b/src/vunit_python_bridge/native/arguments.c new file mode 100644 index 0000000..b2a288c --- /dev/null +++ b/src/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/src/vunit_python_bridge/native/bridge.h b/src/vunit_python_bridge/native/bridge.h new file mode 100644 index 0000000..b743e3b --- /dev/null +++ b/src/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 *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); + +/* 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/src/vunit_python_bridge/native/config.c b/src/vunit_python_bridge/native/config.c new file mode 100644 index 0000000..9bfb83e --- /dev/null +++ b/src/vunit_python_bridge/native/config.c @@ -0,0 +1,201 @@ +/* + * 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 +#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) { + 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; +} + +/* + * 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->run_script_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/src/vunit_python_bridge/native/error.c b/src/vunit_python_bridge/native/error.c new file mode 100644 index 0000000..9adf109 --- /dev/null +++ b/src/vunit_python_bridge/native/error.c @@ -0,0 +1,120 @@ +/* + * 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 +#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) { + char text[4096]; + int length = snprintf(text, sizeof(text), "%s%s", prefix, detail == NULL ? "" : detail); + + 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; } + +/* 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/src/vunit_python_bridge/native/fli.c b/src/vunit_python_bridge/native/fli.c new file mode 100644 index 0000000..a25cf0f --- /dev/null +++ b/src/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/src/vunit_python_bridge/native/interpreter.c b/src/vunit_python_bridge/native/interpreter.c new file mode 100644 index 0000000..c1b7390 --- /dev/null +++ b/src/vunit_python_bridge/native/interpreter.c @@ -0,0 +1,271 @@ +/* + * 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) { + 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. */ + 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(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, "run_script_dir", g_config.run_script_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/src/vunit_python_bridge/native/operations.c b/src/vunit_python_bridge/native/operations.c new file mode 100644 index 0000000..8cd64f4 --- /dev/null +++ b/src/vunit_python_bridge/native/operations.c @@ -0,0 +1,239 @@ +/* + * 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. Optional: every operation + * starts the interpreter if it is not running, see vpy_begin. + */ +VPY_EXPORT int32_t vpy_setup(void) { + flush_c_streams(); + return vpy_initialize(); +} + +/* + * 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/src/vunit_python_bridge/native/vhpi/python_pkg.c b/src/vunit_python_bridge/native/vhpi/python_pkg.c new file mode 100644 index 0000000..543a4e8 --- /dev/null +++ b/src/vunit_python_bridge/native/vhpi/python_pkg.c @@ -0,0 +1,125 @@ +// 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, +// 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/src/vunit_python_bridge/native/vhpi/python_pkg.h b/src/vunit_python_bridge/native/vhpi/python_pkg.h new file mode 100644 index 0000000..3c48b5d --- /dev/null +++ b/src/vunit_python_bridge/native/vhpi/python_pkg.h @@ -0,0 +1,31 @@ +// 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, +// 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/src/vunit_python_bridge/native/vhpi/python_pkg_vhpi.c b/src/vunit_python_bridge/native/vhpi/python_pkg_vhpi.c new file mode 100644 index 0000000..fe6d226 --- /dev/null +++ b/src/vunit_python_bridge/native/vhpi/python_pkg_vhpi.c @@ -0,0 +1,366 @@ +// 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, +// 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/src/vunit_python_bridge/native_library.py b/src/vunit_python_bridge/native_library.py new file mode 100644 index 0000000..c331f24 --- /dev/null +++ b/src/vunit_python_bridge/native_library.py @@ -0,0 +1,455 @@ +# 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, 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. +""" + +import hashlib +import os +import platform +import shlex +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path +from typing import Dict, 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 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": + add_python_dll_to_path() + if simulator_prefix is not None: + return _build_windows_library(root, simulator_prefix) + return _prepare_windows_library(root) + return _prepare_posix_library(root, simulator_prefix) + + +def windows_dll_name() -> str: + """ + File name of the prebuilt bridge DLL for the running Python. + """ + return f"vunit_python_bridge-cp{sys.version_info[0]}{sys.version_info[1]}-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 _check_windows_python() -> None: + """ + 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_name: str, simulator_prefix: Optional[str]) -> 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() + if simulator_prefix is None: + return + # NVC and GHDL name their executable, which VUnit lets the NVC and GHDL environment variables + # override, the Questa/ModelSim and Aldec interfaces use vsim + name = os.environ.get(simulator_name.upper(), simulator_name) if simulator_name in ("nvc", "ghdl") else "vsim" + executable = Path(simulator_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") + + name = windows_dll_name() + source = BINARY_PATH / name + if not source.is_file(): + 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" + 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(candidates: Optional[List[str]] = None) -> List[str]: + """ + Include directories of the Python development headers, by default the ones sysconfig names. + """ + 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: + 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(fli: bool = False) -> List[Path]: + """ + The C source files of the bridge library. fli adds the FLI front end. + """ + sources = sorted(item for item in NATIVE_PATH.glob("*.c") if item.name != FLI_SOURCE_NAME) + if fli: + sources.append(NATIVE_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 = "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(): + 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 []) + ) + return compile_library(cmd, tmp, library_file) + + +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, 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: + 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") + ) + if tmp != library_file: + os.replace(tmp, library_file) + return library_file + + +def windows_gcc(simulator_prefix: Optional[Path] = None) -> Optional[List[str]]: + """ + 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. + """ + 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 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. + """ + # 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]) + + +def _build_windows_library(root: Path, simulator_prefix: Optional[Path] = None) -> Path: + """ + 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. + + 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' 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"{name}.{os.getpid()}.tmp" + cmd = ( + 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{Path(simulator_prefix).resolve()!s}", "-lmtipli"] if simulator_prefix is not None else []) + ) + return compile_library(cmd, tmp, library_file) diff --git a/src/vunit_python_bridge/runtime.py b/src/vunit_python_bridge/runtime.py new file mode 100644 index 0000000..cf2e044 --- /dev/null +++ b/src/vunit_python_bridge/runtime.py @@ -0,0 +1,645 @@ +# 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__ +import numpy # type: ignore[import-not-found] + +# Result kinds, must match python_ffi_pkg +KIND_INTEGER = 0 +KIND_REAL = 1 +KIND_BOOLEAN = 2 +KIND_STRING = 3 +KIND_STD_ULOGIC = 4 +KIND_STD_ULOGIC_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_ULOGIC: "std_ulogic", + KIND_STD_ULOGIC_VECTOR: "std_ulogic_vector", + KIND_SIGNED: "signed", + KIND_UNSIGNED: "unsigned", + KIND_INTEGER_ARRAY: "integer_array_t", + KIND_INTEGER_VECTOR: "integer_vector", + KIND_REAL_VECTOR: "real_vector", +} + +STD_ULOGIC_VALUES = frozenset("UX01ZWLH-") +INTEGER_LOW = -(2**31) +INTEGER_HIGH = 2**31 - 1 +TEXT_ENCODING = "utf-8" +TEXT_ERRORS = "surrogateescape" + +# 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 = "python_bridge:python: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.""" + return isinstance(value, (bool, 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. + """ + return isinstance(value, (float, 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 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) + + @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 "" + + +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, 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__} + __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 = {} + # 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) + + # 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): + """ + 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 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. 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] = { + "__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 + + @staticmethod + def _resolve_file(file_name): + """ + 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") + return Path(os.path.normpath(Path(file_name).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_ulogic_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). + Every use gets its own copy so that a function modifying it in place + does not affect the next use. + """ + 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): + """ + 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 + 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): + """ + 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. + """ + 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_ulogic_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") + + if kind == KIND_INTEGER_ARRAY: + return self._array_result(value, array_meta) + if kind not in VHDL_TYPE_NAMES: + raise RuntimeError(f"Internal error: unknown result kind {kind}") + 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): + """ + 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_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_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_ulogic_vector_result(kind, value, width): + """ + Convert a str result, one character per element. + """ + 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_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): + """ + 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. + """ + original = 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) + 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(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/src/vunit_python_bridge/simulator_hooks.py b/src/vunit_python_bridge/simulator_hooks.py new file mode 100644 index 0000000..0f2bc43 --- /dev/null +++ b/src/vunit_python_bridge/simulator_hooks.py @@ -0,0 +1,141 @@ +# 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 + + +""" +Simulator hooks making the simulator load the bridge library and its dependencies. + +They are registered with the package context by the setup function of the package, one set +per simulator, and are only called for the project they were registered for. + +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 subprocess +import sys +from pathlib import Path +from typing import Dict, List, Optional + +from .bridge import GHDL_LINKING_BACKENDS, PythonBridge + +# The vsim flag keeping the runtime libraries Questa bundles out of the dynamic library +# search path, and the help category listing every vsim flag. +NO_AUTO_LD_LIBRARY_PATH = "-noautoldlibpath" +VSIM_HELP_ARGUMENTS = ("-help", "all") + + +def register(context, bridge: PythonBridge) -> None: + """ + Register the hooks of every simulator the bridge serves with the package context. + """ + context.register_simulator_hooks("nvc", run_flags=_nvc_run_flags(bridge)) + context.register_simulator_hooks( + "ghdl", + elab_flags=_ghdl_elab_flags(bridge), + run_env=_ghdl_run_env(bridge), + ) + context.register_simulator_hooks("modelsim", process_flags=_modelsim_process_flags()) + + +def _nvc_run_flags(bridge: PythonBridge): + """ + NVC run (-r) flags loading the bridge. + """ + + def hook(simulator_interface) -> List[str]: # pylint: disable=unused-argument + return [f"--load={bridge.library_file!s}"] + + return hook + + +def _ghdl_elab_flags(bridge: PythonBridge): + """ + GHDL elaboration flags. Only the backends linking the design ahead of time need them, + the others load the bridge at run time and are served by the run environment. + """ + + def hook(simulator_interface) -> List[str]: + if simulator_interface.backend not in GHDL_LINKING_BACKENDS: + return [] + return [f"-Wl,-L{bridge.library_file.parent!s}"] + + return hook + + +def _ghdl_run_env(bridge: PythonBridge): + """ + Add the bridge directory to the dynamic library search path of a GHDL simulation. + """ + + def hook(simulator_interface, env: Dict[str, str]) -> Dict[str, str]: # pylint: disable=unused-argument + 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.library_file.parent), env.get(variable, "")) if item + ) + return env + + return hook + + +def _modelsim_process_flags(): + """ + Flags for the vsim process VUnit starts for a Questa/ModelSim simulation. + + 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. + + The flag is set up when the process starts, so it is only honoured on the command line + of the vsim process itself, not on the vsim command of the do-file VUnit generates. + + Only relevant on Linux, where LD_LIBRARY_PATH decides this, and only given to a vsim + that knows the flag. + """ + known: Dict[Optional[str], bool] = {} + + def hook(simulator_interface) -> List[str]: + if not sys.platform.startswith("linux"): + return [] + + prefix = simulator_interface.prefix + if prefix not in known: + known[prefix] = _vsim_knows_flag(prefix, simulator_interface) + if not known[prefix]: + return [] + + return [NO_AUTO_LD_LIBRARY_PATH] + + return hook + + +def _vsim_knows_flag(prefix: Optional[str], simulator_interface) -> bool: + """ + Whether the vsim of an installation lists -noautoldlibpath among its options. + + Asking vsim rather than assuming keeps a version without the flag from failing to + start at all. An installation that cannot be asked is taken to know it, since the + flag is what makes the bridge usable there. + """ + if prefix is None: + return True + + try: + output = subprocess.run( + [str(Path(prefix) / "vsim"), *VSIM_HELP_ARGUMENTS], + check=True, + capture_output=True, + text=True, + env=simulator_interface.get_env(), + ).stdout + except (OSError, subprocess.SubprocessError): + return True + + return NO_AUTO_LD_LIBRARY_PATH in output.split() diff --git a/src/vunit_python_bridge/vunit_pkg.toml b/src/vunit_python_bridge/vunit_pkg.toml new file mode 100644 index 0000000..1183a69 --- /dev/null +++ b/src/vunit_python_bridge/vunit_pkg.toml @@ -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 + +[package] +# The VUnit adding packages and simulator hooks, VUnit/vunit#1221 +requires-vunit = ">=5.0.0.dev12" +requires-vhdl = ">=2008" +library = "python_bridge" +setup = "vunit_python_bridge:setup" + +# The foreign language interface implementing python_ffi_pkg is selected by the +# setup function since the choice depends on the simulator. +[[package.sources]] +include = ["hdl/src/python_context.vhd", "hdl/src/python_pkg.vhd"] diff --git a/tests/models/counter.py b/tests/models/counter.py new file mode 100644 index 0000000..6a37519 --- /dev/null +++ b/tests/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/tests/models/filters.py b/tests/models/filters.py new file mode 100644 index 0000000..8f0d33f --- /dev/null +++ b/tests/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/tests/models/importer.py b/tests/models/importer.py new file mode 100644 index 0000000..cbe0c4a --- /dev/null +++ b/tests/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/tests/models/reference_model.py b/tests/models/reference_model.py new file mode 100644 index 0000000..647dcd8 --- /dev/null +++ b/tests/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/tests/run.py b/tests/run.py new file mode 100644 index 0000000..f91833b --- /dev/null +++ b/tests/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 tests of the vunit-python-bridge 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 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", + "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_package("vunit-python-bridge", allow_setup=True) + + 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 / "tb_python_pkg.vhd") + if simulator_name in BRIDGE_SIMULATORS: + # The operations implemented by the Python bridge + lib.add_source_file(ROOT / "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/tests/tb_python_pkg.vhd b/tests/tb_python_pkg.vhd new file mode 100644 index 0000000..bdbb3f2 --- /dev/null +++ b/tests/tb_python_pkg.vhd @@ -0,0 +1,398 @@ +-- 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; + +library python_bridge; +context python_bridge.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 + -- 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); + + 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(default_logger, failure); + vhdl_int := eval("2**63"); + check_only_log( + default_logger, + "eval(""2**63"") failed:" & LF & + "OverflowError: 9223372036854775808 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(default_logger); + + elsif run("Test eval of integer with underflow from Python to C") then + mock(default_logger, failure); + vhdl_int := eval("-2**63 - 1"); + check_only_log( + default_logger, + "eval(""-2**63 - 1"") failed:" & LF & + "OverflowError: -9223372036854775809 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(default_logger); + + elsif run("Test eval of integer with overflow from C to VHDL") then + mock(default_logger, failure); + vhdl_int := eval("2**31"); + check_only_log( + default_logger, + "eval(""2**31"") failed:" & LF & + "OverflowError: 2147483648 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(default_logger); + + elsif run("Test eval of integer with underflow from C to VHDL") then + mock(default_logger, failure); + vhdl_int := eval("-2**31 - 1"); + check_only_log( + default_logger, + "eval(""-2**31 - 1"") failed:" & LF & + "OverflowError: -2147483649 is outside the range of VHDL integer " & integer_range, + failure + ); + unmock(default_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(default_logger, failure); + vhdl_real := eval("float('inf')"); + check_only_log( + default_logger, + "eval(""float('inf')"") failed:" & LF & + "ValueError: inf cannot be represented as VHDL real", + failure + ); + unmock(default_logger); + + elsif run("Test eval of real with underflow from C to VHDL") then + check_equal(eval("-1.0e300"), -1.0e300); + + mock(default_logger, failure); + vhdl_real := eval("-float('inf')"); + check_log( + default_logger, + "eval(""-float('inf')"") failed:" & LF & + "ValueError: -inf cannot be represented as VHDL real", + failure + ); + vhdl_real := eval("float('nan')"); + check_only_log( + default_logger, + "eval(""float('nan')"") failed:" & LF & + "ValueError: nan cannot be represented as VHDL real", + failure + ); + unmock(default_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(default_logger, failure); + exec( + "doing_something_right = 17" & LF & + "doing_something_wrong = doing_something_right_misspelled" + ); + check_only_log( + default_logger, + "exec failed:" & LF & eval_string("expected_error(failing_source, '')"), + failure + ); + unmock(default_logger); + + elsif run("Test exceptions in eval") then + define_error_helper; + + mock(default_logger, failure); + vhdl_int := eval("1 / 0"); + check_only_log( + default_logger, + "eval(""1 / 0"") failed:" & LF & + call_string( + "expected_error", arg(string'("1 / 0")), arg(string'("")), arg(true) + ), + failure + ); + unmock(default_logger); + + elsif run("Test eval with type error") then + mock(default_logger, failure); + vhdl_int := eval("10 / 2"); + check_only_log( + default_logger, + "eval(""10 / 2"") failed:" & LF & + "TypeError: Cannot convert Python float (5.0) to VHDL integer; expected int", + failure + ); + unmock(default_logger); + + elsif run("Test raising exception") then + define_error_helper; + + mock(default_logger, failure); + exec("raise RuntimeError('An exception')"); + check_only_log( + default_logger, + "exec failed:" & LF & + call_string( + "expected_error", + arg(string'("raise RuntimeError('An exception')")), + arg(string'("")) + ), + failure + ); + unmock(default_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/tests/tb_python_pkg_bridge.vhd b/tests/tb_python_pkg_bridge.vhd new file mode 100644 index 0000000..203e238 --- /dev/null +++ b/tests/tb_python_pkg_bridge.vhd @@ -0,0 +1,1068 @@ +-- 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; +library python_bridge; +context python_bridge.python_context; + +entity tb_python_pkg_bridge is + generic (runner_cfg : string); +end entity; + +architecture tb of tb_python_pkg_bridge is + constant std_ulogic_characters : string(1 to 9) := "UX01ZWLH-"; + 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 := "'''"; + + -- 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 := new_session("golden"); + constant fixed_point : python_session_t := new_session("fixed_point"); + + -- 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); + 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"; + 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; + + 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 + 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 + -- 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(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("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("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("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(default_logger, failure); + exec_file("models/does_not_exist.py"); + check_only_log( + 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(default_logger); + + elsif run("Test that a Python exception with traceback logs a failure") then + define_error_helper; + mock(default_logger, failure); + exec("raise ValueError('boom')"); + check_only_log( + default_logger, + "exec failed:" & LF & + call_string( + "expected_error", arg(string'("raise ValueError('boom')")), arg(string'("")) + ), + failure + ); + unmock(default_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(default_logger, failure); + check_equal(eval_real("1"), 0.0); + check_only_log( + default_logger, + "eval(""1"") failed:" & LF & + "TypeError: Cannot convert Python int (1) to VHDL real; expected float", + failure + ); + unmock(default_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_ulogic round trip of all 9 states") then + exec("def identity(x):" + " return x"); + for idx in std_ulogic_characters'range loop + check_equal( + call_std_ulogic("identity", arg(string'(1 => std_ulogic_characters(idx)))), + std_ulogic'val(idx - 1) + ); + end loop; + + elsif run("Test std_ulogic_vector round trip with descending range") then + exec("def identity(x):" + " return x"); + result_vec9 := call_std_ulogic_vector("identity", arg(to_string(slv9_desc))); + check_equal(result_vec9, slv9_desc); + + elsif run("Test std_ulogic_vector round trip with ascending range") then + exec("def identity(x):" + " return x"); + 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 + 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(default_logger, failure); + discard(eval_integer_vector("[1, 2.0]")); + check_log( + 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", + failure + ); + discard(eval_integer_vector("[True]")); + check_log( + default_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( + default_logger, + "eval(""17"") failed:" & LF & + "TypeError: Cannot convert Python int (17) to VHDL integer_vector; expected a list or tuple", + failure + ); + 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(default_logger, failure); + discard(eval_real_vector("[1.0, 2]")); + check_log( + 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( + 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(default_logger); + + elsif run("Test that a wrong return type fails") then + exec("def returns_none():" + " return None"); + mock(default_logger, failure); + discard_int := call("returns_none"); + check_only_log( + default_logger, + "eval(""returns_none()"") failed:" & LF & + "TypeError: Cannot convert Python NoneType (None) to VHDL integer; expected int", + failure + ); + unmock(default_logger); + + elsif run("Test that integer overflow fails") then + exec("def too_big():" + " return 2**31"); + mock(default_logger, failure); + discard_int := call("too_big"); + check_only_log( + default_logger, + "eval(""too_big()"") failed:" & LF & + "OverflowError: 2147483648 is outside the range of VHDL integer (-2147483648 to 2147483647)", + failure + ); + unmock(default_logger); + + elsif run("Test that signed overflow in a procedure result fails") then + exec("def identity(x):" + " return x"); + mock(default_logger, failure); + call_signed("identity", s8, arg(200)); + check_only_log( + default_logger, + "eval(""identity(200)"") failed:" & LF & + "OverflowError: 200 does not fit in a 8 bit signed (-128 to 127)", + failure + ); + unmock(default_logger); + + elsif run("Test that unsigned overflow in a procedure result fails") then + exec("def identity(x):" + " return x"); + mock(default_logger, failure); + call_unsigned("identity", u8, arg(-1)); + check_only_log( + default_logger, + "eval(""identity(-1)"") failed:" & LF & + "OverflowError: -1 does not fit in a 8 bit unsigned (0 to 255)", + failure + ); + 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(default_logger, failure); + call_std_ulogic_vector("wrong_length_bits", slv4); + check_only_log( + default_logger, + "eval(""wrong_length_bits()"") failed:" & LF & + "ValueError: Got 5 std_ulogic values but the VHDL result has length 4", + failure + ); + unmock(default_logger); + + elsif run("Test that an undefined function fails") then + define_error_helper; + mock(default_logger, failure); + discard_int := call("no_such_function"); + check_only_log( + default_logger, + "eval(""no_such_function()"") failed:" & LF & + call_string( + "expected_error", arg(string'("no_such_function()")), arg(string'("")), arg(true) + ), + failure + ); + unmock(default_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_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_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"); + 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)); + + --------------------------------------------------------------------- + -- 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(default_logger, failure); + check_equal(call_string("describe", kwarg("a", 1) & kwarg("a", 2)), ""); + check_only_log( + 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(default_logger); + + elsif run("Test that a keyword argument group before a positional argument is a Python error") then + define_error_helper; + mock(default_logger, failure); + check_equal(call_string("describe", kwarg("a", 1) & kwarg("b", 2), arg(3)), ""); + check_only_log( + 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(default_logger); + + 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", kwarg("a", 1) & arg(2)), ""); + check_log(python_logger, group_error, failure); + 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) & arg(4))), ""); + check_only_log(python_logger, group_error, failure); + unmock(python_logger); + + --------------------------------------------------------------------- + -- Wide and std_ulogic 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 + mock(python_logger, failure); + discard(arg_unsigned(unsigned'("1010X010"))); + check_log( + python_logger, "arg_unsigned cannot convert ""1010X010""; the value has metavalues", failure + ); + discard(arg_signed(signed'("10Z0"))); + check_log(python_logger, "arg_signed cannot convert ""10Z0""; the value has metavalues", failure); + discard(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_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_ulogic argument fails") then + mock(python_logger, failure); + discard(arg('X')); + check_log(python_logger, "arg cannot convert 'X'; expected '0', '1', 'L' or 'H'", failure); + 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; + -- 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( + default_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( + 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; + 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(fixed_point_logger, failure); + discard_int := call("only_in_golden", session => fixed_point); + check_only_log( + 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(fixed_point_logger); + + elsif run("Test executing a file in different sessions") then + 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); + + 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(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 + ); + 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(get_id(golden) = get_id("golden", parent => p_python_id)); + check_equal(full_name(get_id(golden)), "python_bridge:python:golden"); + check_equal(get_full_name(get_logger(get_id(golden))), "python_bridge: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; + + test_runner_cleanup(runner); + end process; +end architecture; diff --git a/tests/test_python_bridge.py b/tests/test_python_bridge.py new file mode 100644 index 0000000..8563c0d --- /dev/null +++ b/tests/test_python_bridge.py @@ -0,0 +1,1131 @@ +# 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 vunit-python-bridge package (src/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 tempfile +import unittest +from contextlib import contextmanager +from glob import glob +from pathlib import Path +from unittest import mock + +if sys.version_info >= (3, 11): + import tomllib +else: + 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 + +PACKAGE_ROOT = Path(vunit_python_bridge.__file__).parent.resolve() +MANIFEST = PACKAGE_ROOT / "vunit_pkg.toml" + + +@contextmanager +def create_tempdir(): + """ + A temporary directory as a Path. + """ + with tempfile.TemporaryDirectory() as name: + yield Path(name).resolve() + + +# 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 put on sys.path by the runtime. + """ + path.write_text("# run script stub\n", encoding="utf-8") + return path + + +class _FakeLibrary: + """ + The Library object of the package, as seen through the package context. + """ + + def __init__(self, name="python_bridge"): + self.name = name + + +class _FakeContext: + """ + A stand-in for vunit.package_context.PackageContext. + """ + + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, simulator_class, output_path, run_script_path, simulator_prefix=None, simulator_backend=None + ): + self.simulator_class = simulator_class + self.output_path = output_path + self.run_script_path = run_script_path + self.simulator_prefix = simulator_prefix + self.simulator_backend = simulator_backend + self.library = _FakeLibrary() + self.added_files = [] + self.hooks = {} + + @property + def simulator_name(self): + return None if self.simulator_class is None else self.simulator_class.name + + def add_source_files(self, library_name, pattern, vhdl_standard=None): + self.added_files += [(library_name, Path(item)) for item in pattern] + + def register_simulator_hooks( + self, simulator_name, *, elab_flags=None, run_flags=None, process_flags=None, run_env=None + ): + self.hooks[simulator_name] = dict( + elab_flags=elab_flags, run_flags=run_flags, process_flags=process_flags, run_env=run_env + ) + + +class TestManifest(unittest.TestCase): + """ + vunit_pkg.toml: what VUnit reads before the setup function is called. + """ + + def setUp(self): + with MANIFEST.open("rb") as fptr: + self.package = tomllib.load(fptr)["package"] + + def test_library_is_python_bridge(self): + self.assertEqual(self.package["library"], "python_bridge") + + def test_setup_points_at_the_setup_function(self): + self.assertEqual(self.package["setup"], "vunit_python_bridge:setup") + module_name, function_name = self.package["setup"].split(":") + self.assertEqual(module_name, vunit_python_bridge.__name__) + self.assertIs(getattr(vunit_python_bridge, function_name), vunit_python_bridge.setup) + + def test_requires_vhdl_2008(self): + self.assertEqual(self.package["requires-vhdl"], ">=2008") + + def test_requires_a_vunit_version(self): + self.assertTrue(self.package["requires-vunit"].startswith(">=")) + + def test_sources_are_the_simulator_independent_vhdl(self): + includes = [item for source in self.package["sources"] for item in source["include"]] + self.assertEqual( + sorted(includes), + ["hdl/src/python_context.vhd", "hdl/src/python_pkg.vhd"], + ) + for include in includes: + self.assertTrue((PACKAGE_ROOT / include).is_file(), include) + + def test_the_simulator_dependent_vhdl_is_not_listed(self): + includes = [item for source in self.package["sources"] for item in source["include"]] + for name in ("python_pkg_vhpi.vhd", "python_ffi_pkg_bridge.vhd"): + self.assertNotIn(f"hdl/src/{name}", includes) + + +class TestForeignLanguageInterfaces(unittest.TestCase): + """ + The table deciding how a simulator is served. VUnit only says which simulator was + selected, so the package has to know the foreign language interface of each of them. + """ + + def test_every_supported_simulator_has_its_interface(self): + self.assertEqual( + vunit_python_bridge.FOREIGN_LANGUAGE_INTERFACES, + { + "nvc": "VHPIDIRECT_NVC", + "ghdl": "VHPIDIRECT_GHDL", + "modelsim": "FLI", + "rivierapro": "VHPI", + "activehdl": "VHPI", + }, + ) + + def test_the_simulators_the_bridge_serves_are_the_ones_it_is_set_up_for(self): + # Everything that is not the VHPI application is served by the bridge, which has + # to know the same simulators. + served_by_the_bridge = { + name + for name, interface in vunit_python_bridge.FOREIGN_LANGUAGE_INTERFACES.items() + if interface != "VHPI" + } + self.assertEqual(served_by_the_bridge, set(bridge_setup.BRIDGE_SIMULATORS)) + + def test_the_message_names_every_simulator(self): + for simulator in ("NVC", "GHDL", "Questa/ModelSim", "Riviera-PRO", "Active-HDL"): + self.assertIn(simulator, vunit_python_bridge.SUPPORTED_SIMULATORS) + + +class TestPackageSetup(unittest.TestCase): + """ + vunit_python_bridge.setup(context): the foreign language interface of the selected + simulator is added on top of the sources of vunit_pkg.toml, with bridge.setup() + stubbed so no compilation happens here. + """ + + 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 _context(self, simulator_class, prefix="/sim/bin", backend=None): + return _FakeContext(simulator_class, self.tempdir / "out", self.run_script, prefix, backend) + + @staticmethod + def _simulator(name): + """ + A simulator interface class as the setup function sees it, that is by name. + """ + simulator = mock.Mock() + simulator.name = name + return simulator + + def _added_names(self, context): + return {path.name for _, path in context.added_files} + + def test_rejects_simulator_class_none(self): + with self.assertRaisesRegex(RuntimeError, "no simulator was found"): + vunit_python_bridge.setup(self._context(None)) + + def test_rejects_unsupported_simulator(self): + context = self._context(self._simulator("some_simulator")) + with self.assertRaisesRegex(RuntimeError, "no foreign language interface for some_simulator") as ctx: + vunit_python_bridge.setup(context) + # The message names the simulators so a user knows what is supported. + self.assertIn(vunit_python_bridge.SUPPORTED_SIMULATORS, str(ctx.exception)) + + def test_vhpi_adds_python_pkg_vhpi_and_builds_the_application(self): + for name in ("rivierapro", "activehdl"): + self._check_vhpi_application_built(name) + + def _check_vhpi_application_built(self, name): + simulator = self._simulator(name) + context = self._context(simulator) + with mock.patch("vunit_python_bridge.bridge.setup") as setup_mock, mock.patch( + "vunit_python_bridge.foreign_application.setup_vhpi_application" + ) as vhpi_mock: + vunit_python_bridge.setup(context) + 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({library for library, _ in context.added_files}, {"python_bridge"}) + # The VHPI application needs no simulator hooks + self.assertEqual(context.hooks, {}) + + def test_application_build_failure_is_reported(self): + context = self._context(self._simulator("rivierapro")) + with mock.patch( + "vunit_python_bridge.foreign_application.setup_vhpi_application", + side_effect=RuntimeError("no compiler"), + ): + with self.assertRaisesRegex(RuntimeError, "no compiler"): + vunit_python_bridge.setup(context) + + def _fake_bridge(self): + return 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", + ], + ) + + def _check_bridge_files_added(self, simulator, backend=None): + """ + The generated bridge files, and not the VHPI application package, are added + for a simulator served by the Python bridge. + """ + context = self._context(simulator, backend=backend) + fake_bridge = self._fake_bridge() + with mock.patch("vunit_python_bridge.bridge.setup", return_value=fake_bridge) as setup_mock: + vunit_python_bridge.setup(context) + + # The bridge must be handed the output path, the run script path (whose directory + # the runtime puts on sys.path, like python does for a run script) and the + # simulator as the context knows it: its name, prefix and backend. + setup_mock.assert_called_once_with( + context.output_path, + context.run_script_path, + simulator.name, + context.simulator_prefix, + context.simulator_backend, + ) + + for expected in fake_bridge.vhdl_files: + self.assertIn(("python_bridge", expected), context.added_files) + self.assertNotIn("python_pkg_vhpi.vhd", self._added_names(context)) + return context + + def test_vhpidirect_adds_the_bridge_files(self): + self._check_bridge_files_added(self._simulator("nvc")) + self._check_bridge_files_added(self._simulator("ghdl"), backend="mcode") + + 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("modelsim")) + + def test_fli_uses_the_bridge_not_the_vhpi_application(self): + context = self._context(self._simulator("modelsim")) + with mock.patch("vunit_python_bridge.bridge.setup", return_value=self._fake_bridge()), mock.patch( + "vunit_python_bridge.foreign_application.setup_vhpi_application" + ) as vhpi_mock: + vunit_python_bridge.setup(context) + vhpi_mock.assert_not_called() + + def test_hooks_are_registered_for_every_simulator_the_bridge_serves(self): + context = self._check_bridge_files_added(self._simulator("nvc")) + self.assertEqual(sorted(context.hooks), ["ghdl", "modelsim", "nvc"]) + self.assertIsNotNone(context.hooks["nvc"]["run_flags"]) + self.assertIsNotNone(context.hooks["ghdl"]["elab_flags"]) + self.assertIsNotNone(context.hooks["ghdl"]["run_env"]) + self.assertIsNotNone(context.hooks["modelsim"]["process_flags"]) + # Questa finds the library by the absolute path in its FLI attributes + self.assertIsNone(context.hooks["modelsim"]["run_env"]) + + def test_bridge_setup_failure_is_reported(self): + context = self._context(self._simulator("nvc")) + with mock.patch( + "vunit_python_bridge.bridge.setup", + side_effect=native_library.PythonBridgeError("no compiler"), + ): + with self.assertRaisesRegex(native_library.PythonBridgeError, "no compiler"): + vunit_python_bridge.setup(context) + + def test_importing_the_package_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, vunit_python_bridge): + importlib.reload(module) + run_mock.assert_not_called() + self.assertEqual(listing(), before) + + +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_name, backend=None, 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(self.tempdir / "out", self.run_script, simulator_name, "/sim/bin", backend) + + def _fli_setup(self): + return self._setup("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("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("nvc")) + declared = set(re.findall(r'VHPIDIRECT \S+ (\w+)"', text)) + self.assertEqual(declared, set(EXPECTED_EXPORTS)) + + 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_name, backend, token in ( + ("nvc", None, "libvunit_python_bridge.so"), + ("ghdl", "mcode", "libvunit_python_bridge.so"), + ("ghdl", "llvm-jit", "libvunit_python_bridge.so"), + ("ghdl", "llvm", "-lvunit_python_bridge"), + ("ghdl", "gcc", "-lvunit_python_bridge"), + ): + with self.subTest(simulator=simulator_name, backend=backend): + self.assertIn(f'"VHPIDIRECT {token} vpy_begin"', self._ffi_text(self._setup(simulator_name, backend))) + + def test_no_remaining_placeholder(self): + for bridge in (self._setup("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(self.tempdir / "out", self.run_script, "modelsim", "/sim/bin") + # The simulator prefix selects the FLI variant of the library. + self.assertEqual(prepare_mock.call_args.args[1], Path("/sim/bin")) + + def test_fli_without_a_simulator_prefix_raises(self): + # The FLI variant is built against the simulator installation, so there has to be one. + with self.assertRaisesRegex(RuntimeError, "it was not found"): + bridge_setup.setup(self.tempdir / "out", self.run_script, "modelsim", None) + + 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(self.tempdir / "out", self.run_script, "nvc", "/sim/bin") + 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("rivierapro") + + def test_all_vhpidirect_tokens_fit_ghdl_limit(self): + for simulator_name, backend in ( + ("nvc", None), + ("ghdl", "mcode"), + ("ghdl", "llvm"), + ("ghdl", "gcc"), + ): + bridge = self._setup(simulator_name, backend) + 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(output_path or self.tempdir / "out", 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): + # A static build is not a macOS framework build either + if name == "Py_ENABLE_SHARED": + return 0 + if name == "PYTHONFRAMEWORK": + return "" + 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() + # macOS lists the external symbols of a dylib with -gU and prefixes C names with "_" + darwin = sys.platform == "darwin" + options = ["-gU"] if darwin else ["-D", "--defined-only"] + proc = subprocess.run( + [nm, *options, 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][1:] if darwin and fields[-1].startswith("_") else 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) + 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 + """ + + 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 = _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) + self.assertEqual(keys["runtime"], str(bridge_setup.RUNTIME_SOURCE)) + 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("/run/script/dir") # pylint: disable=protected-access + keys = _config_keys(text) + 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_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" + with mock.patch("vunit_python_bridge.bridge.prepare_library", return_value=fake_library_file): + bridge_setup.setup(tempdir / "out", run_script) + config = (fake_library_file.parent / bridge_setup.CONFIG_FILE_NAME).read_text(encoding="utf-8") + 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): + 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("/run/script/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): + 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) + 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), + 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_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("vunit_python_bridge.native_library.sysconfig.get_platform", return_value="win-amd64"), + mock.patch.object(native_library, "_build_windows_library", return_value=Path("built.dll")) as build, + ): + 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"): + with self.assertRaisesRegex(RuntimeError, "64-bit"): + 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 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. + """ + + @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_name, simulator_prefix): + with ( + mock.patch("sys.platform", "win32"), + mock.patch.object(native_library.sysconfig, "get_platform", return_value="win-amd64"), + mock.patch.dict(os.environ), + ): + os.environ.pop("GHDL", None) + native_library.check_windows_64bit_simulator(simulator_name, simulator_prefix) + + 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("ghdl", str(tempdir / "x64")) + self._check("ghdl", str(tempdir / "missing")) + self._check("ghdl", 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("ghdl", str(tempdir / kind)) + + def test_vsim_is_checked_for_questa(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("modelsim", 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("ghdl", 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("ghdl", None) + + +class TestSimulatorHooks(unittest.TestCase): + """ + The hooks registered with the package context: what NVC, GHDL and Questa/ModelSim + are given for a simulation of the project. + """ + + def _hooks(self, library_file): + bridge = bridge_setup.PythonBridge(library_file=library_file, vhdl_files=[]) + context = _FakeContext(None, Path("/out"), Path("/run.py")) + simulator_hooks.register(context, bridge) + return context.hooks + + @staticmethod + def _ghdl(backend): + """ + A GHDL interface as a hook gets it: it knows its own backend. + """ + simulator = mock.Mock() + simulator.backend = backend + return simulator + + @staticmethod + def _questa(prefix="/questa/bin", help_output=f"{simulator_hooks.NO_AUTO_LD_LIBRARY_PATH} Disable it"): + """ + A Questa/ModelSim interface and the vsim help output the hook asks it for. + """ + simulator = mock.Mock() + simulator.prefix = prefix + simulator.get_env.return_value = None + return simulator, mock.patch( + "vunit_python_bridge.simulator_hooks.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, stdout=help_output, stderr=""), + ) + + def test_nvc_loads_the_library_at_run_time(self): + library_file = Path("/some/dir/libvunit_python_bridge.so") + hooks = self._hooks(library_file) + self.assertEqual(hooks["nvc"]["run_flags"](mock.Mock()), [f"--load={library_file!s}"]) + # NVC dlopen()s the library itself, nothing to do at elaboration + self.assertIsNone(hooks["nvc"]["elab_flags"]) + + def test_ghdl_elab_flags_empty_for_mcode_and_jit(self): + hooks = self._hooks(Path("/some/dir/libvunit_python_bridge.so")) + for backend in ("mcode", "llvm-jit"): + self.assertEqual(hooks["ghdl"]["elab_flags"](self._ghdl(backend)), [], backend) + + def test_ghdl_elab_flags_for_linking_backends(self): + bridge_dir = Path("/some/dir") + hooks = self._hooks(bridge_dir / "libvunit_python_bridge.so") + for backend in ("llvm", "gcc"): + self.assertEqual(hooks["ghdl"]["elab_flags"](self._ghdl(backend)), [f"-Wl,-L{bridge_dir!s}"], backend) + + def test_ghdl_run_env_prepends_ld_library_path_without_mutating_input(self): + bridge_dir = Path("/some/dir") + hooks = self._hooks(bridge_dir / "libvunit_python_bridge.so") + env = {"LD_LIBRARY_PATH": "/existing/path"} + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "linux"): + result = hooks["ghdl"]["run_env"](mock.Mock(), 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") + hooks = self._hooks(bridge_dir / "libvunit_python_bridge.so") + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "darwin"): + result = hooks["ghdl"]["run_env"](mock.Mock(), {}) + 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") + hooks = self._hooks(bridge_dir / "vunit_python_bridge.dll") + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "win32"): + result = hooks["ghdl"]["run_env"](mock.Mock(), {}) + self.assertEqual(result["PATH"], str(bridge_dir)) + self.assertNotIn("LD_LIBRARY_PATH", result) + + def test_modelsim_gets_no_run_flags(self): + # -noautoldlibpath is only honoured on the command line of the vsim process + # VUnit starts, which is what process_flags extends. + hooks = self._hooks(Path("/some/dir/libvunit_python_bridge_fli.so")) + self.assertIsNone(hooks["modelsim"]["run_flags"]) + self.assertIsNone(hooks["modelsim"]["elab_flags"]) + + def test_modelsim_process_flags_disable_the_bundled_cxx_runtime_on_linux(self): + hooks = self._hooks(Path("/some/dir/libvunit_python_bridge_fli.so")) + simulator, help_patch = self._questa() + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "linux"), help_patch as run: + self.assertEqual(hooks["modelsim"]["process_flags"](simulator), ["-noautoldlibpath"]) + # The answer of an installation is remembered, vsim is only asked once. + self.assertEqual(hooks["modelsim"]["process_flags"](simulator), ["-noautoldlibpath"]) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args[0][0][0], str(Path("/questa/bin/vsim"))) + + def test_modelsim_process_flags_empty_when_vsim_does_not_know_the_flag(self): + hooks = self._hooks(Path("/some/dir/libvunit_python_bridge_fli.so")) + simulator, help_patch = self._questa(help_output="-nothing -of -interest") + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "linux"), help_patch: + self.assertEqual(hooks["modelsim"]["process_flags"](simulator), []) + + def test_modelsim_process_flags_kept_when_vsim_cannot_be_asked(self): + hooks = self._hooks(Path("/some/dir/libvunit_python_bridge_fli.so")) + simulator, _ = self._questa() + no_vsim = mock.patch("vunit_python_bridge.simulator_hooks.subprocess.run", side_effect=OSError("no vsim")) + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "linux"), no_vsim: + self.assertEqual(hooks["modelsim"]["process_flags"](simulator), ["-noautoldlibpath"]) + + def test_modelsim_process_flags_empty_on_windows(self): + hooks = self._hooks(Path("/some/dir/vunit_python_bridge_fli.dll")) + simulator, help_patch = self._questa() + with mock.patch("vunit_python_bridge.simulator_hooks.sys.platform", "win32"), help_patch as run: + self.assertEqual(hooks["modelsim"]["process_flags"](simulator), []) + run.assert_not_called() + + +class TestForeignApplicationBuild(unittest.TestCase): + """ + The VHPI application is built under the output path once and rebuilt when its inputs change. + """ + + def test_builds_once_and_rebuilds_when_the_fingerprint_changes(self): + with create_tempdir() as tempdir: + output_path = Path(tempdir) / "out" + prefix = str(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, "rivierapro", prefix) + foreign_application.setup_vhpi_application(output_path, "rivierapro", prefix) + 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 = str(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, "rivierapro", 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, "rivierapro", 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" + prefix = str(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, "rivierapro", prefix) + self.assertFalse(list((output_path / "rivierapro" / "libraries").glob("*.fingerprint"))) + + def test_a_missing_simulator_installation_is_reported(self): + with self.assertRaisesRegex(RuntimeError, "it was not found"): + foreign_application.setup_vhpi_application(Path("/out"), "rivierapro", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_python_bridge.py b/tools/build_python_bridge.py new file mode 100644 index 0000000..19cee4c --- /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 VUnit 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 src/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 src/vunit_python_bridge/native_library.py by path, without importing vunit and its dependencies. + """ + path = Path(__file__).parent.parent / "src" / "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() diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 0000000..2242953 --- /dev/null +++ b/tools/release.py @@ -0,0 +1,76 @@ +# 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 version of the package, as the release workflow needs it. + +A release is made by tagging the commit bumping the version in pyproject.toml, so the +tag and that version must agree. Checking it here keeps a mistyped tag from reaching +PyPI, where a version cannot be taken back. + + python tools/release.py version print the version of pyproject.toml + python tools/release.py validate check that the version is a release version + python tools/release.py validate --tag v1.2.3 + check the tag against it as well +""" + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +PYPROJECT = Path(__file__).parent.parent.resolve() / "pyproject.toml" + +# The versions a release may have: MAJOR.MINOR.PATCH, optionally a pre-release of it. +RELEASE_VERSION = re.compile(r"\d+\.\d+\.\d+(a\d+|b\d+|rc\d+)?") + + +def version() -> str: + """ + The version in pyproject.toml. + """ + with PYPROJECT.open("rb") as fptr: + return tomllib.load(fptr)["project"]["version"] + + +def validate(tag) -> None: + """ + Fail unless the version, and the tag naming it when there is one, are what a release + is made of. + """ + project_version = version() + if not RELEASE_VERSION.fullmatch(project_version): + raise SystemExit( + f"{PYPROJECT} has version {project_version!r}, which is not a version to release. " + "Releases are made of MAJOR.MINOR.PATCH, optionally aN, bN or rcN." + ) + + if tag is not None and tag != f"v{project_version}": + raise SystemExit( + f"The tag {tag!r} does not name the version of {PYPROJECT}, which is {project_version!r}. " + f"Expected the tag v{project_version}." + ) + + print(f"Version {project_version}" + ("" if tag is None else f", tagged {tag}")) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("version", help="Print the version of pyproject.toml") + validate_parser = commands.add_parser("validate", help="Check the version, and a tag naming it") + validate_parser.add_argument("--tag", help="The tag the release is made from, for example v1.2.3") + + args = parser.parse_args() + if args.command == "version": + print(version()) + else: + validate(args.tag) + + +if __name__ == "__main__": + sys.exit(main())