From 9d9bb91a8006e36dbc3e0c8425770583c2140e85 Mon Sep 17 00:00:00 2001 From: Ross Owen Date: Thu, 18 Jun 2026 23:29:36 +0100 Subject: [PATCH 1/3] Refactor bus wire API to use driver handles --- lib/python/Pyxsim/bus.py | 114 +++++++++++--- tests/test_bus_line_model.py | 282 +++++++++++++++++++++++++++++------ 2 files changed, 326 insertions(+), 70 deletions(-) diff --git a/lib/python/Pyxsim/bus.py b/lib/python/Pyxsim/bus.py index 0f7a04c..4717d39 100644 --- a/lib/python/Pyxsim/bus.py +++ b/lib/python/Pyxsim/bus.py @@ -106,8 +106,8 @@ def disable_pullup(self): """Disable the modeled pull-up bias.""" self.pullup_enabled = False - def set_driver(self, driver, mode, context=None): - """Set a named driver's hard-drive mode.""" + def _set_driver(self, driver, mode, context=None): + """Internal method to set a named driver's hard-drive mode.""" if not isinstance(mode, DriveMode): mode = DriveMode(mode) driver = self._key(driver) @@ -115,27 +115,6 @@ def set_driver(self, driver, mode, context=None): if mode != DriveMode.HIGH_Z: self.assert_no_hard_clash(driver=driver, mode=mode, context=context) - def release(self, driver): - """Set a named driver to high-Z.""" - self.set_driver(driver, DriveMode.HIGH_Z) - - def drive(self, driver, value, context=None): - """Set a named driver to hard-drive low or high.""" - if value not in (0, 1): - raise ValueError("BusWire drive value must be 0 or 1") - if value == 1: - self.drive_high(driver, context=context) - else: - self.drive_low(driver, context=context) - - def drive_low(self, driver, context=None): - """Set a named driver to hard-drive low.""" - self.set_driver(driver, DriveMode.DRIVE_LOW, context=context) - - def drive_high(self, driver, context=None): - """Set a named driver to hard-drive high.""" - self.set_driver(driver, DriveMode.DRIVE_HIGH, context=context) - def driver_mode(self, driver): """Return a named driver's mode, defaulting to high-Z.""" return self._drivers.get(self._key(driver), DriveMode.HIGH_Z) @@ -240,3 +219,92 @@ def _debug_snapshot(self, snapshot, context=None, exclude=None): f"pullup={snapshot.pullup_enabled} " f"released_value={self.released_value}" ) + + def _drive(self, driver_name: str, value: int, context=None): + """Internal drive method for OO API.""" + if value not in (0, 1): + raise ValueError("BusWire drive value must be 0 or 1") + mode = DriveMode.DRIVE_HIGH if value == 1 else DriveMode.DRIVE_LOW + self._set_driver(driver_name, mode, context=context) + + def _release(self, driver_name: str): + """Internal release method for OO API.""" + self._set_driver(driver_name, DriveMode.HIGH_Z) + + +class BusWireDriver: + """Bound driver handle for a specific driver on a specific wire.""" + + def __init__(self, wire: BusWire, driver: 'BusDriver'): + self._wire = wire + self._driver = driver + + def drive(self, value: int, context=None): + """Drive this wire to the specified value (0 or 1).""" + self._wire._drive(self._driver.name, value, context=context) + + def release(self): + """Release this driver to high-Z.""" + self._wire._release(self._driver.name) + + def mode(self) -> DriveMode: + """Return the current drive mode of this driver.""" + return self._wire.driver_mode(self._driver.name) + + +class BusDriver: + """Named driver that can be bound to multiple wires.""" + + def __init__(self, name: str): + self.name = name + self._wires: dict[str, BusWireDriver] = {} + + def _bind_wire(self, wire: BusWire): + """Internal method to bind a wire to this driver.""" + # Validate wire name is a valid Python identifier + if not wire.name.isidentifier(): + raise ValueError(f"Wire name must be a valid Python identifier: '{wire.name}'") + + # Check for conflicts with existing BusDriver attributes + if hasattr(self, wire.name): + raise ValueError(f"Wire name '{wire.name}' conflicts with BusDriver attribute") + + # Check for duplicate binding + if wire.name in self._wires: + raise ValueError(f"Driver '{self.name}' is already bound to wire '{wire.name}'") + + # Create the wire driver handle and bind it as a real attribute + wire_driver = BusWireDriver(wire, self) + self._wires[wire.name] = wire_driver + setattr(self, wire.name, wire_driver) + + +class Bus: + """Container that binds drivers to wires and enforces global name uniqueness.""" + + def __init__(self, wires: list[BusWire], drivers: list[BusDriver]): + # Validate wire name uniqueness first + wire_names = [w.name for w in wires] + if len(wire_names) != len(set(wire_names)): + duplicates = [name for name in wire_names if wire_names.count(name) > 1] + raise ValueError(f"Duplicate wire names: {set(duplicates)}") + + # Validate driver name uniqueness + driver_names = [d.name for d in drivers] + if len(driver_names) != len(set(driver_names)): + duplicates = [name for name in driver_names if driver_names.count(name) > 1] + raise ValueError(f"Duplicate driver names: {set(duplicates)}") + + # Validate global uniqueness across all wire and driver names + all_names = wire_names + driver_names + if len(all_names) != len(set(all_names)): + duplicates = [name for name in all_names if all_names.count(name) > 1] + raise ValueError(f"Duplicate names found (wires and drivers must have globally unique names): {set(duplicates)}") + + self._wires = {w.name: w for w in wires} + self._drivers = {d.name: d for d in drivers} + + # Bind all drivers to all wires + for driver in drivers: + for wire in wires: + driver._bind_wire(wire) diff --git a/tests/test_bus_line_model.py b/tests/test_bus_line_model.py index c4e0dd7..dafb55b 100644 --- a/tests/test_bus_line_model.py +++ b/tests/test_bus_line_model.py @@ -1,15 +1,19 @@ # Copyright 2026 XMOS LIMITED. # This Software is subject to the terms of the XMOS Public Licence: Version 1. import pytest -from Pyxsim.bus import BusWire, BusMode, BusWire, BusWireHardClash, DriveMode +from Pyxsim.bus import Bus, BusDriver, BusWire, BusMode, BusWireHardClash, DriveMode def test_bus_wire_all_released_resolves_to_released_value(): - line = BusWire("sda", released_value=1) - line.release("controller") - line.release("target") + sda = BusWire("sda", released_value=1) + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) - snapshot = line.snapshot() + controller.sda.release() + target.sda.release() + + snapshot = sda.snapshot() assert snapshot.resolved == 1 assert snapshot.hard_clash is False @@ -17,11 +21,15 @@ def test_bus_wire_all_released_resolves_to_released_value(): def test_bus_wire_hard_low_beats_released(): - line = BusWire("sda") - line.drive_low("controller") - line.release("target") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(0) + target.sda.release() - snapshot = line.snapshot() + snapshot = sda.snapshot() assert snapshot.resolved == 0 assert snapshot.hard_clash is False @@ -29,33 +37,45 @@ def test_bus_wire_hard_low_beats_released(): def test_bus_wire_hard_high_beats_released(): - line = BusWire("sda") - line.drive_high("controller") - line.release("target") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(1) + target.sda.release() - snapshot = line.snapshot() + snapshot = sda.snapshot() assert snapshot.resolved == 1 assert snapshot.hard_clash is False assert snapshot.drivers_high == ("controller",) def test_bus_wire_multiple_low_drivers_do_not_clash(): - line = BusWire("sda") - line.drive_low("controller") - line.drive_low("target") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) - snapshot = line.snapshot() + controller.sda.drive(0) + target.sda.drive(0) + + snapshot = sda.snapshot() assert snapshot.resolved == 0 assert snapshot.hard_clash is False assert snapshot.drivers_low == ("controller", "target") def test_bus_wire_low_and_high_drivers_clash(): - line = BusWire("sda") - line.drive_low("controller") + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(0) with pytest.raises(BusWireHardClash) as exc_info: - line.drive_high("target") + target.sda.drive(1) snapshot = exc_info.value.snapshot assert snapshot.resolved == 0 @@ -64,64 +84,232 @@ def test_bus_wire_low_and_high_drivers_clash(): assert snapshot.drivers_high == ("target",) -def test_bus_wire_accepts_drive_mode_values(): - line = BusWire("sda") - line.set_driver("controller", DriveMode.DRIVE_HIGH.value) - - assert line.driver_mode("controller") == DriveMode.DRIVE_HIGH - assert line.resolved_value() == 1 - - def test_bus_wire_release_with_pullup_resolves_high(): - wire = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) - wire.release("controller") - wire.release("target") + sda = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.release() + target.sda.release() - snapshot = wire.snapshot() + snapshot = sda.snapshot() assert snapshot.resolved == 1 assert snapshot.mode == BusMode.OPEN_DRAIN assert snapshot.pullup_enabled is True def test_bus_wire_release_without_pullup_uses_released_value(): - wire = BusWire("sda", pullup_enabled=False, released_value=0) - wire.release("controller") + sda = BusWire("sda", pullup_enabled=False, released_value=0) + controller = BusDriver("controller") + Bus([sda], [controller]) - assert wire.resolved_value() == 0 + controller.sda.release() + + assert sda.resolved_value() == 0 def test_bus_wire_warns_once_when_floating_without_pullup_or_released_value(capsys): - wire = BusWire("sda", pullup_enabled=False) - wire.release("controller") + sda = BusWire("sda", pullup_enabled=False) + controller = BusDriver("controller") + Bus([sda], [controller]) + + controller.sda.release() - assert wire.resolved_value() is None - assert wire.resolved_value() is None + assert sda.resolved_value() is None + assert sda.resolved_value() is None captured = capsys.readouterr() assert captured.out.count("Bus wire sda is floating") == 1 def test_bus_wire_configures_mode_and_pullup(): - wire = BusWire("sda", released_value=0) + sda = BusWire("sda", released_value=0) + controller = BusDriver("controller") + Bus([sda], [controller]) - wire.configure(mode=BusMode.PUSH_PULL, pullup_enabled=False) + sda.configure(mode=BusMode.PUSH_PULL, pullup_enabled=False) - snapshot = wire.snapshot() + snapshot = sda.snapshot() assert snapshot.mode == BusMode.PUSH_PULL assert snapshot.pullup_enabled is False def test_bus_wire_drive_value_sets_hard_drive_mode(): - wire = BusWire("sda") + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) - wire.drive("controller", 1) + controller.sda.drive(1) - assert wire.driver_mode("controller") == DriveMode.DRIVE_HIGH + assert controller.sda.mode() == DriveMode.DRIVE_HIGH @pytest.mark.parametrize("value", [-1, 2, 3]) def test_bus_wire_drive_rejects_non_single_wire_values(value): - wire = BusWire("sda") + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) with pytest.raises(ValueError, match="0 or 1"): - wire.drive("controller", value) + controller.sda.drive(value) + + +# New OO API-specific tests + +def test_bus_binds_drivers_to_multiple_wires(): + """Test that Bus binds all drivers to all wires.""" + scl = BusWire("scl", pullup_enabled=False) + sda = BusWire("sda", pullup_enabled=True) + controller = BusDriver("controller") + target = BusDriver("target") + + Bus([scl, sda], [controller, target]) + + # All drivers should be bound to all wires + controller.scl.drive(1) + controller.sda.drive(0) + target.scl.release() + target.sda.release() + + assert scl.resolved_value() == 1 + assert sda.resolved_value() == 0 + + +def test_bus_rejects_duplicate_wire_names(): + """Test that Bus raises on duplicate wire names.""" + sda1 = BusWire("sda") + sda2 = BusWire("sda") + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="Duplicate wire names"): + Bus([sda1, sda2], [controller]) + + +def test_bus_rejects_duplicate_driver_names(): + """Test that Bus raises on duplicate driver names.""" + sda = BusWire("sda") + controller1 = BusDriver("controller") + controller2 = BusDriver("controller") + + with pytest.raises(ValueError, match="Duplicate driver names"): + Bus([sda], [controller1, controller2]) + + +def test_bus_enforces_global_uniqueness(): + """Test that wire and driver names must be globally unique.""" + sda = BusWire("sda") + sda_driver = BusDriver("sda") # Same name as wire + + with pytest.raises(ValueError, match="globally unique"): + Bus([sda], [sda_driver]) + + +def test_driver_attribute_access_for_unbound_wire_raises(): + """Test that accessing unbound wire raises AttributeError.""" + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) + + with pytest.raises(AttributeError, match="'BusDriver' object has no attribute 'scl'"): + controller.scl.drive(0) + + +def test_bus_wire_driver_mode_queries(): + """Test that BusWireDriver.mode() returns correct DriveMode.""" + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([sda], [controller]) + + # Initially high-Z + assert controller.sda.mode() == DriveMode.HIGH_Z + + # After driving low + controller.sda.drive(0) + assert controller.sda.mode() == DriveMode.DRIVE_LOW + + # After driving high + controller.sda.drive(1) + assert controller.sda.mode() == DriveMode.DRIVE_HIGH + + # After release + controller.sda.release() + assert controller.sda.mode() == DriveMode.HIGH_Z + + +def test_driver_release_only_affects_that_driver(): + """Test that releasing one driver doesn't affect other drivers.""" + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(0) + target.sda.drive(0) + + # Both driving low + assert sda.resolved_value() == 0 + assert controller.sda.mode() == DriveMode.DRIVE_LOW + assert target.sda.mode() == DriveMode.DRIVE_LOW + + # Release controller only + controller.sda.release() + + # Controller released, target still driving + assert sda.resolved_value() == 0 + assert controller.sda.mode() == DriveMode.HIGH_Z + assert target.sda.mode() == DriveMode.DRIVE_LOW + + +def test_hard_clash_still_detected_with_oo_api(): + """Test that BusWireHardClash is still raised with OO API.""" + sda = BusWire("sda") + controller = BusDriver("controller") + target = BusDriver("target") + Bus([sda], [controller, target]) + + controller.sda.drive(1) + + with pytest.raises(BusWireHardClash) as exc_info: + target.sda.drive(0) + + assert exc_info.value.snapshot.hard_clash is True + + +def test_bus_rejects_invalid_wire_name(): + """Test that Bus rejects wire names that are not valid Python identifiers.""" + sda_invalid = BusWire("sda-line") # Hyphen not allowed in identifiers + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="must be a valid Python identifier"): + Bus([sda_invalid], [controller]) + + +def test_bus_rejects_wire_name_conflicting_with_driver_attribute(): + """Test that Bus rejects wire names that conflict with BusDriver attributes.""" + name_wire = BusWire("name") # Conflicts with BusDriver.name + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="conflicts with BusDriver attribute"): + Bus([name_wire], [controller]) + + +def test_driver_bound_wires_are_real_attributes(): + """Test that bound wires are real attributes, not __getattr__ magic.""" + scl = BusWire("scl") + sda = BusWire("sda") + controller = BusDriver("controller") + Bus([scl, sda], [controller]) + + # Attributes should be in __dict__ + assert "scl" in controller.__dict__ + assert "sda" in controller.__dict__ + + # Should be able to use hasattr + assert hasattr(controller, "scl") + assert hasattr(controller, "sda") + assert not hasattr(controller, "nonexistent") + + # Should be able to use getattr with default + assert getattr(controller, "scl") is not None + assert getattr(controller, "nonexistent", "default") == "default" From e30992e672e100e05f2fddf4baaa20d6975b5208 Mon Sep 17 00:00:00 2001 From: Ross Owen Date: Mon, 22 Jun 2026 11:53:55 +0100 Subject: [PATCH 2/3] Updated documentation --- README.rst | 168 ++++++++----------------- doc/rst/test_support.rst | 241 ++++++++++++++++++++++++++++++++++++ lib/python/Pyxsim/pyxsim.py | 5 + 3 files changed, 300 insertions(+), 114 deletions(-) create mode 100644 doc/rst/test_support.rst diff --git a/README.rst b/README.rst index 578625d..ec2bf0b 100644 --- a/README.rst +++ b/README.rst @@ -1,132 +1,72 @@ +:orphan: -Test Support -============ +#################################### +test_support: XMOS test support tools +#################################### -This repo contains helpers for testing XMOS xCORE applictions and includes the following: +:vendor: XMOS +:scope: General Use +:description: Python helpers for testing XMOS xCORE applications and libraries +:category: Testing +:keywords: test, xsim, pyxsim, pytest, coverage +:devices: xcore-200, xcore.ai -- Python wrapper for the xCORE simulator (xsim) -- Python access functions for XE files -- Python code coverage measurement (xcov) for pytest +******* +Summary +******* -Basic usage: Pyxsim -------------------- +``test_support`` provides shared Python infrastructure for XMOS simulation and +testbench code. It includes helpers for running xsim from pytest, interacting +with simulated ports and pins, comparing simulator output, reading XE metadata, +and modelling resolved bus wires in Python testbenches. -Pyxsim provides Python helpers for running xsim-based tests from pytest. A -typical test builds a simulator thread, optionally attaches plugins, and calls -``Pyxsim.run_on_simulator_`` with a tester such as ``ComparisonTester``. +******** +Features +******** -When pytest output capture is passed via ``capfd``, Pyxsim captures simulator -and simthread output and replays it according to the pytest verbosity level: +* Python wrapper for the xCORE simulator XSI interface. +* Pytest-oriented simulator runners and output comparison helpers. +* Simulator thread support for Python-driven testbench activity. +* Resolved wire model for buses with multiple named drivers. +* Python access helpers for XE files. +* Python code coverage support for xsim traces. -* ``pytest ...`` keeps output compact and reports pass/fail details. -* ``pytest ... -v`` enables verbose tester output, including captured - ``OUTPUT:`` lines from the simulator stream. -* ``pytest ... -vv`` also prints the matching ``GOLDEN:`` lines from - ``ComparisonTester`` and is useful when diagnosing mismatches. +************ +Known issues +************ -``ComparisonTester`` supports regular-expression matching, ordered or unordered -comparison, ignored output patterns, and optional suppression of common xsim -multidrive diagnostics. +* None -Resolved bus wires ------------------- +**************** +Development repo +**************** -``Pyxsim.bus`` contains a small resolved-wire model for Python testbenches: +* `test_support `_ (https://www.github.com/xmos/test_support) -* ``BusWire`` models one digital bus wire with named drivers, protocol mode, - and pull-up state. -* ``DriveMode.HIGH_Z`` represents a released line driver. -* ``DriveMode.DRIVE_LOW`` and ``DriveMode.DRIVE_HIGH`` represent hard-driven - values. -* When all drivers are high-Z and pull-up is enabled, the wire resolves high. -* If any driver drives low, the resolved value is low unless another driver is - hard-driving high. -* A hard-low and hard-high combination is reported as ``hard_clash`` in the - wire snapshot. -* Hard-drive operations raise ``BusWireHardClash`` immediately if they create a - hard-low versus hard-high conflict. The exception includes the wire name, - resolved value, active low drivers, active high drivers, and the driver/mode - being applied. +************** +Required tools +************** -Example:: +* Python 3.12 +* XMOS XTC Tools - from Pyxsim.bus import BusMode, BusWire +********************************* +Required libraries (dependencies) +********************************* - sda = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) - sda.release("controller") - sda.drive("target", 0) - - snapshot = sda.snapshot() - assert snapshot.resolved == 0 - assert not snapshot.hard_clash - -Use ``release(driver)`` when the testbench is handing ownership of a wire to -another bus participant. Use ``drive(driver, 1)`` only when the protocol phase -really requires a hard high drive. If the wire has a modeled pull-up, released -drivers can still resolve to a high value without being recorded as hard-driving -high. - -Basic usage: xcoverage ----------------------- - -This only suit for xsim. - -It requires disassembly and elf file which dumped from binary file (.xe file) by: - - * xobjdump --split [.xe]. - * xobjdump -S [.xe] -o [output_file_name.dump]. - * run the above 2 step by youself or run method from xcov: generate_elf_disasm("/path_to/(name-of-xe).xe", "/path_where_store_elf_and_disasm", "/path_to/(name-of-disasm).dump") - -.xe must make with -g flag to enable the gdb bugger otherwise xcoverage won't work!. - -It also needs a tracing file from xsim by running: - - * xsim --trace-to [output_file_name.txt] [.xe]. - -``xcov_process`` -....................... - -This is the main function to be called in your test. -It returns the average coverage and save the data in .xcov file in xcov dir. -.xcov file is necessary for the below "xcov_combine" function. - -xcov_process(disasm, trace, xcov_dir). - - * @param disam: path to disasm file. - * @param trace: path to trace file. - * @param xcov_dir : path where xcov directory locates. - * @return average coverage of all src file. - * @output generate xcov file for xcov_combine and save in xcov dir. - -``xcov_combine`` -....................... - -see example in examples/code_coverage - -``combine_process`` -....................... - -see example in examples/code_coverage - -``Mark the source code as not expected to be hit`` -........................................................ - -Add a comment "//NOCOVER" or "//NOCOVERSTART" and "//NOCOVEREND" beside you source code. It wouldn't be counted in coverage. - -see example in test/test_xcoverage - -``Excluded File`` -........................................................ -Passing an excluded_file arg in xcov_process(), eg: - -xcov_process(disasm, trace, xcov_dir, excluded_file=["/tests/shared/test_main.xc","/tests/shared/shared.h" ]) - -Software version and dependencies -................................. - -The CHANGELOG contains information about the current and previous versions. -For a list of direct dependencies, look for DEPENDENT_MODULES in test_support/module_build_info. +* ``colorama`` +******** +Examples +******** +* ``examples/pyxsim`` demonstrates a simulator thread interacting with an xCORE application. +* ``examples/code_coverage`` demonstrates xsim trace based coverage processing. +******* +Support +******* +This package is supported by XMOS Ltd. Issues can be raised against the software +at `www.xmos.com/support `_ or using GitHub +`issues `_. diff --git a/doc/rst/test_support.rst b/doc/rst/test_support.rst new file mode 100644 index 0000000..620246c --- /dev/null +++ b/doc/rst/test_support.rst @@ -0,0 +1,241 @@ +############ +test_support +############ + +************ +Introduction +************ + +``test_support`` provides shared Python infrastructure for XMOS simulation and +testbench code. It wraps the XSI simulator interface, provides helper classes +for simulator threads and plugins, implements comparison testers, and includes +common models used by library tests. + +The main Python modules are: + +* ``Pyxsim.pyxsim``: XSI wrapper, simulator thread support, plugin execution, + and pin/port access helpers. +* ``Pyxsim.bus``: resolved wire and bus-driver model for Python testbenches. +* ``Pyxsim.testers``: output comparison and filtering helpers. +* ``Pyxsim.xe``: helpers for reading metadata from XE files. + +***** +Usage +***** + +Install ``test_support`` into the Python environment used by the tests. During +library development this is normally done from the repository checkout in +editable mode: + +.. code-block:: console + + python -m pip install -e . + +Tests usually import ``Pyxsim`` and run a built ``.xe`` under xsim. A typical +pytest test constructs any required simulator threads, creates a tester for the +expected output, and passes pytest's ``capfd`` fixture so simulator output can be +captured and replayed consistently. + +.. code-block:: python + + from pathlib import Path + + import Pyxsim + from Pyxsim.testers import ComparisonTester + + + def test_example(capfd): + xe = Path("bin/example.xe") + tester = ComparisonTester(["ready", "done"]) + + assert Pyxsim.run_on_simulator_( + xe, + tester=tester, + capfd=capfd, + ) + +Pass ``simargs`` for xsim command-line arguments and ``appargs`` for arguments +passed to the simulated application. + +***************** +Comparison Tester +***************** + +``ComparisonTester`` compares simulator output with expected lines. The expected +output can be supplied as a list of strings, a newline-separated string, or a +file object. + +By default, comparison is ordered and exact. Set ``regexp=True`` to treat each +expected line as a regular expression. Set ``ordered=False`` when the expected +lines may appear in any order. + +.. code-block:: python + + tester = ComparisonTester( + [r"status: [0-9]+", "done"], + regexp=True, + ordered=True, + ) + +Use ``ignore`` to suppress known non-deterministic or irrelevant lines: + +.. code-block:: python + + tester = ComparisonTester( + ["done"], + ignore=[r"timestamp: .*"], + ) + +Common xsim multidrive diagnostics are suppressed by default. Pass +``suppress_multidrive_messages=False`` if a test needs to assert on those +messages directly. + +When pytest output capture is passed via ``capfd``, Pyxsim captures simulator and +simthread output and replays it according to the pytest verbosity level: + +* ``pytest ...`` keeps output compact and reports pass/fail details. +* ``pytest ... -v`` enables verbose tester output, including captured + ``OUTPUT:`` lines from the simulator stream. +* ``pytest ... -vv`` also prints the matching ``GOLDEN:`` lines from + ``ComparisonTester`` and is useful when diagnosing mismatches. + +***************** +Simulator Threads +***************** + +Simulator threads let Python testbench code interact with the simulated xCORE +application while xsim is running. Define a class derived from +``Pyxsim.SimThread`` and implement ``run()``. + +.. code-block:: python + + import Pyxsim + + + class ExampleSimThread(Pyxsim.SimThread): + def run(self): + trigger = "tile[0]:XS1_PORT_1G" + response = "tile[0]:XS1_PORT_1H" + + self.wait_for_port_pins_change([trigger]) + value = self.xsi.sample_port_pins(response) + self.xsi.drive_port_pins(response, 1 - value) + +Common simthread helpers are: + +* ``wait_for_port_pins_change(ports)``: wait until one of the supplied ports + changes drive state or value. +* ``wait_for_next_cycle()``: yield until the simulator advances. +* ``wait_until(time)``: wait until the simulator reaches an absolute time. +* ``wait(predicate)``: wait until a Python predicate returns true. +* ``xsi.drive_port_pins(port, value)``: drive a simulator port from Python. +* ``xsi.sample_port_pins(port)``: sample the current value of a simulator port. +* ``xsi.is_port_driving(port)``: test whether the xCORE application is actively + driving a port pin. + +************************* +XSI Port Sampling Warning +************************* + +``sample_port_pins`` should not be treated as a pure observation in all +testbench situations. Sampling a port through XSI can affect Python-driven port +state in the simulator. This matters when Python code is also driving a resolved +value onto the same port, for example when emulating pull-ups or mirroring a +resolved bus model into xsim. + +If a caller only wants to know whether the xCORE target is actively driving a +port, check ``is_port_driving()`` before calling ``sample_port_pins()``. Only +sample when the target is known to be driving; otherwise keep the corresponding +Python-side bus driver released. + +.. code-block:: python + + if xsi.is_port_driving(sda_port): + target_value = xsi.sample_port_pins(sda_port) + target.sda.drive(target_value) + else: + target.sda.release() + +This avoids accidentally disturbing the Python-driven resolved value while still +allowing target-driven values to be mirrored into the Python bus model. + +******************* +Resolved Bus Model +******************* + +``Pyxsim.bus`` models digital wires that may be driven by multiple named bus +participants. It distinguishes high-Z release from hard-drive high so that tests +can model open-drain and push-pull behaviour and detect hard clashes. + +Create physical wires and driver objects, then bind them with ``Bus``: + +.. code-block:: python + + from Pyxsim.bus import Bus, BusDriver, BusMode, BusWire + + scl = BusWire("scl", mode=BusMode.PUSH_PULL, pullup_enabled=False) + sda = BusWire("sda", mode=BusMode.OPEN_DRAIN, pullup_enabled=True) + + controller = BusDriver("controller") + target = BusDriver("target") + + Bus([scl, sda], [controller, target]) + + controller.scl.drive(1) + controller.sda.release() + target.sda.drive(0) + + value = sda.resolved_value() + +The ``Bus`` constructor validates wire and driver names and binds each driver to +each wire. Wire handles are exposed as attributes on the driver, such as +``controller.sda``. + +``DriveMode.HIGH_Z`` represents a released line driver. ``DriveMode.DRIVE_LOW`` +and ``DriveMode.DRIVE_HIGH`` represent hard-driven values. When all drivers are +high-Z and pull-up is enabled, the wire resolves high. If any driver drives low, +the resolved value is low unless another driver is hard-driving high. + +Use ``release(driver)`` when the testbench is handing ownership of a wire to +another bus participant. Use ``drive(driver, 1)`` only when the protocol phase +really requires a hard high drive. If the wire has a modeled pull-up, released +drivers can still resolve to a high value without being recorded as hard-driving +high. + +A hard-low and hard-high combination is reported as ``hard_clash`` in the wire +snapshot. Hard-drive operations raise ``BusWireHardClash`` immediately if they +create a hard-low versus hard-high conflict. The exception includes the wire +name, resolved value, active low drivers, active high drivers, and the +driver/mode being applied. + +********************* +Tracing and Debugging +********************* + +Enable instruction tracing with ``instTracing=True`` and VCD tracing with +``vcdTracing=True`` when running a simulation. Trace files are written under the +``logs`` directory using the ``xsim_trace_`` prefix. + +.. code-block:: python + + Pyxsim.run_on_simulator_( + xe, + simthreads=[ExampleSimThread()], + instTracing=True, + vcdTracing=True, + ) + +Use the ``timeout`` argument to bound long-running simulations. If the simulator +process does not exit before the timeout expires, Pyxsim terminates it and the +run fails. + +******** +Examples +******** + +``examples/pyxsim`` contains a small simulator-thread example. The xCORE +application changes one port, the Python simthread observes that change, then the +simthread drives another port to wake the application. + +``examples/code_coverage`` contains a minimal example of processing xsim trace +data for source coverage. diff --git a/lib/python/Pyxsim/pyxsim.py b/lib/python/Pyxsim/pyxsim.py index 7181837..b95d867 100644 --- a/lib/python/Pyxsim/pyxsim.py +++ b/lib/python/Pyxsim/pyxsim.py @@ -208,6 +208,9 @@ def drive_port_pins(self, port, val): self._xsi.drive_port_pins(tile, p, mask, val) def sample_port_pins(self, port): + # WARNING: XSI port sampling can affect Python-driven port state. If a + # caller only wants to observe xcore-driven state, check + # is_port_driving() before sampling. (tile, p, bit, mask) = parse_port(port) val = self._xsi.sample_port_pins(tile, p, mask) if bit: @@ -358,6 +361,8 @@ def sample_pin(self, package, pin): return c_value.value def sample_port_pins(self, tile, port, mask): + # WARNING: xsi_sample_port_pins() is not always a passive observation; + # it can disturb Python-driven port state in xsim/XSI interactions. c_tile = c_char_p(tile.encode("utf-8")) c_port = c_char_p(port.encode("utf-8")) c_mask = c_uint(mask) From dc8a08247c24cec64b30b94c0d58237bc59006d2 Mon Sep 17 00:00:00 2001 From: Ross Owen Date: Mon, 22 Jun 2026 12:10:21 +0100 Subject: [PATCH 3/3] Fix bus wire name validation and duplicate checks Validate wire names before attribute binding so invalid API input reports clear ValueErrors, ensure repeated binds hit the intended duplicate guard, and make duplicate-name diagnostics deterministic. --- lib/python/Pyxsim/bus.py | 42 ++++++++++++++++++++++-------------- tests/test_bus_line_model.py | 26 +++++++++++++++++++--- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/lib/python/Pyxsim/bus.py b/lib/python/Pyxsim/bus.py index 4717d39..c0c7617 100644 --- a/lib/python/Pyxsim/bus.py +++ b/lib/python/Pyxsim/bus.py @@ -262,44 +262,54 @@ def __init__(self, name: str): def _bind_wire(self, wire: BusWire): """Internal method to bind a wire to this driver.""" # Validate wire name is a valid Python identifier - if not wire.name.isidentifier(): + if not isinstance(wire.name, str) or not wire.name.isidentifier(): raise ValueError(f"Wire name must be a valid Python identifier: '{wire.name}'") - - # Check for conflicts with existing BusDriver attributes - if hasattr(self, wire.name): - raise ValueError(f"Wire name '{wire.name}' conflicts with BusDriver attribute") - + # Check for duplicate binding if wire.name in self._wires: raise ValueError(f"Driver '{self.name}' is already bound to wire '{wire.name}'") - + + # Check for conflicts with existing BusDriver attributes + if hasattr(self, wire.name): + raise ValueError(f"Wire name '{wire.name}' conflicts with BusDriver attribute") + # Create the wire driver handle and bind it as a real attribute wire_driver = BusWireDriver(wire, self) self._wires[wire.name] = wire_driver setattr(self, wire.name, wire_driver) +def _duplicate_names(names): + seen = set() + duplicates = set() + for name in names: + if name in seen: + duplicates.add(name) + seen.add(name) + return sorted(duplicates) + + class Bus: """Container that binds drivers to wires and enforces global name uniqueness.""" def __init__(self, wires: list[BusWire], drivers: list[BusDriver]): # Validate wire name uniqueness first wire_names = [w.name for w in wires] - if len(wire_names) != len(set(wire_names)): - duplicates = [name for name in wire_names if wire_names.count(name) > 1] - raise ValueError(f"Duplicate wire names: {set(duplicates)}") + duplicate_wire_names = _duplicate_names(wire_names) + if duplicate_wire_names: + raise ValueError(f"Duplicate wire names: {duplicate_wire_names}") # Validate driver name uniqueness driver_names = [d.name for d in drivers] - if len(driver_names) != len(set(driver_names)): - duplicates = [name for name in driver_names if driver_names.count(name) > 1] - raise ValueError(f"Duplicate driver names: {set(duplicates)}") + duplicate_driver_names = _duplicate_names(driver_names) + if duplicate_driver_names: + raise ValueError(f"Duplicate driver names: {duplicate_driver_names}") # Validate global uniqueness across all wire and driver names all_names = wire_names + driver_names - if len(all_names) != len(set(all_names)): - duplicates = [name for name in all_names if all_names.count(name) > 1] - raise ValueError(f"Duplicate names found (wires and drivers must have globally unique names): {set(duplicates)}") + duplicate_names = _duplicate_names(all_names) + if duplicate_names: + raise ValueError(f"Duplicate names found (wires and drivers must have globally unique names): {duplicate_names}") self._wires = {w.name: w for w in wires} self._drivers = {d.name: d for d in drivers} diff --git a/tests/test_bus_line_model.py b/tests/test_bus_line_model.py index dafb55b..95a04fe 100644 --- a/tests/test_bus_line_model.py +++ b/tests/test_bus_line_model.py @@ -182,7 +182,7 @@ def test_bus_rejects_duplicate_wire_names(): sda2 = BusWire("sda") controller = BusDriver("controller") - with pytest.raises(ValueError, match="Duplicate wire names"): + with pytest.raises(ValueError, match=r"Duplicate wire names: \['sda'\]"): Bus([sda1, sda2], [controller]) @@ -192,7 +192,7 @@ def test_bus_rejects_duplicate_driver_names(): controller1 = BusDriver("controller") controller2 = BusDriver("controller") - with pytest.raises(ValueError, match="Duplicate driver names"): + with pytest.raises(ValueError, match=r"Duplicate driver names: \['controller'\]"): Bus([sda], [controller1, controller2]) @@ -201,7 +201,7 @@ def test_bus_enforces_global_uniqueness(): sda = BusWire("sda") sda_driver = BusDriver("sda") # Same name as wire - with pytest.raises(ValueError, match="globally unique"): + with pytest.raises(ValueError, match=r"globally unique names\): \['sda'\]"): Bus([sda], [sda_driver]) @@ -285,6 +285,26 @@ def test_bus_rejects_invalid_wire_name(): Bus([sda_invalid], [controller]) +def test_bus_rejects_non_string_wire_name(): + """Test that Bus rejects non-string wire names with ValueError.""" + wire = BusWire(123) + controller = BusDriver("controller") + + with pytest.raises(ValueError, match="must be a valid Python identifier"): + Bus([wire], [controller]) + + +def test_driver_rejects_duplicate_wire_binding(): + """Test that duplicate wire binding raises the intended error.""" + wire = BusWire("line") + controller = BusDriver("controller") + + controller._bind_wire(wire) + + with pytest.raises(ValueError, match="already bound"): + controller._bind_wire(wire) + + def test_bus_rejects_wire_name_conflicting_with_driver_attribute(): """Test that Bus rejects wire names that conflict with BusDriver attributes.""" name_wire = BusWire("name") # Conflicts with BusDriver.name