From a79f309bb558aacb6a5deb8198d3183df806f318 Mon Sep 17 00:00:00 2001 From: Lars Asplund Date: Sat, 25 Jan 2025 11:26:35 +0100 Subject: [PATCH] Added support for running testbenches depending on a set of user-provided source files. The intended use case is to feed VUnit with files that has been changed since some point in time. The changes are typically provided by the version control system --- tests/unit/test_ui.py | 103 ++++++++++++++++++++++++++++++++++++++++++ vunit/project.py | 12 ++--- vunit/ui/__init__.py | 88 +++++++++++++++++++++++++++++++----- 3 files changed, 185 insertions(+), 18 deletions(-) diff --git a/tests/unit/test_ui.py b/tests/unit/test_ui.py index 10f1b628c..1fb11d59f 100644 --- a/tests/unit/test_ui.py +++ b/tests/unit/test_ui.py @@ -1265,6 +1265,109 @@ def test_get_testbench_files(self): sorted(expected, key=lambda x: x.name), ) + @with_tempdir + def test_run_dependent(self, tempdir): + def setup(ui): + "Setup the project" + ui.add_vhdl_builtins() + lib1 = ui.add_library("lib1") + lib2 = ui.add_library("lib2") + + rtl = [] + for i in range(4): + rtl_source = f"""\ +entity rtl{i} is +end entity; + +architecture a of rtl{i} is +begin +end architecture; +""" + file_name = str(Path(tempdir) / f"rtl{i}.vhd") + self.create_file(file_name, rtl_source) + rtl.append(lib1.add_source_file(file_name)) + + tb = [] + for i in range(2): + file_name = str(Path(tempdir) / f"tb{i}.vhd") + create_vhdl_test_bench_file( + f"tb{i}", + file_name, + tests=["Test 1"] if i == 0 else [], + ) + if i == 0: + tb.append(lib1.add_source_file(file_name)) + else: + tb.append(lib2.add_source_file(file_name)) + + rtl[1].add_dependency_on(rtl[0]) + rtl[2].add_dependency_on(rtl[0]) + tb[0].add_dependency_on(rtl[1]) + tb[1].add_dependency_on(rtl[2]) + + return rtl, tb + + def check_stdout(ui, expected): + "Check that stdout matches expected" + with mock.patch("sys.stdout", autospec=True) as stdout: + self._run_main(ui) + text = "".join([call[1][0] for call in stdout.write.mock_calls]) + # @TODO not always in the same order in Python3 due to dependency graph + print(text) + self.assertEqual(set(text.splitlines()), set(expected.splitlines())) + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([rtl[0]._source_file.name]) + check_stdout(ui, "lib1.tb0.Test 1\nlib2.tb1.all\nListed 2 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([rtl[1]._source_file.name]) + check_stdout(ui, "lib1.tb0.Test 1\nListed 1 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([Path(rtl[2]._source_file.name)]) + check_stdout(ui, "lib2.tb1.all\nListed 1 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([tb[0]._source_file.name]) + check_stdout(ui, "lib1.tb0.Test 1\nListed 1 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([tb[1]._source_file.name]) + check_stdout(ui, "lib2.tb1.all\nListed 1 tests") + + ui = self._create_ui("--list", "*tb0*") + rtl, tb = setup(ui) + ui.run_dependent([tb[1]._source_file.name]) + check_stdout(ui, "lib1.tb0.Test 1\nlib2.tb1.all\nListed 2 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([tb[1]._source_file.name, rtl[1]._source_file.name]) + check_stdout(ui, "lib1.tb0.Test 1\nlib2.tb1.all\nListed 2 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([tb[1]._source_file.name, "Missing file"]) + check_stdout(ui, "lib2.tb1.all\nListed 1 tests") + + a_dir = Path(tempdir) / "a_dir" + a_dir.mkdir() + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([tb[1]._source_file.name, a_dir]) + check_stdout(ui, "lib2.tb1.all\nListed 1 tests") + + ui = self._create_ui("--list") + rtl, tb = setup(ui) + ui.run_dependent([rtl[3]._source_file.name]) + check_stdout(ui, "Listed 0 tests") + def test_get_simulator_name(self): ui = self._create_ui() self.assertEqual(ui.get_simulator_name(), "mock") diff --git a/vunit/project.py b/vunit/project.py index e384e6019..689aa23e5 100644 --- a/vunit/project.py +++ b/vunit/project.py @@ -500,7 +500,7 @@ def get_files_in_compile_order(self, incremental=True, dependency_graph=None, fi files_to_recompile = self._get_files_to_recompile( files or self.get_source_files_in_order(), dependency_graph, incremental ) - return self._get_affected_files_in_compile_order(files_to_recompile, dependency_graph.get_dependent) + return self.get_affected_files_in_compile_order(files_to_recompile, dependency_graph.get_dependent) def _get_files_to_recompile(self, files, dependency_graph, incremental): """ @@ -527,15 +527,15 @@ def get_dependencies_in_compile_order(self, target_files=None, implementation_de target_files = self._source_files_in_order dependency_graph = self.create_dependency_graph(implementation_dependencies) - return self._get_affected_files_in_compile_order(set(target_files), dependency_graph.get_dependencies) + return self.get_affected_files_in_compile_order(set(target_files), dependency_graph.get_dependencies) - def _get_affected_files_in_compile_order(self, target_files, get_depend_func): + def get_affected_files_in_compile_order(self, target_files, get_depend_func): """ Returns the affected files in compile order given a list of target files and a dependencie function :param target_files: The files to compile :param get_depend_func: one of DependencyGraph [get_dependencies, get_dependent, get_direct_dependencies] """ - affected_files = self._get_affected_files(target_files, get_depend_func) + affected_files = self.get_affected_files(target_files, get_depend_func) return self._get_compile_order(affected_files, get_depend_func.__self__) def get_minimal_file_set_in_compile_order(self, target_files=None): @@ -546,7 +546,7 @@ def get_minimal_file_set_in_compile_order(self, target_files=None): ### # First get all files that are required to fullfill the dependencies for the target files dependency_graph = self.create_dependency_graph(True) - dependency_files = self._get_affected_files( + dependency_files = self.get_affected_files( target_files or self.get_source_files_in_order(), dependency_graph.get_dependencies, ) @@ -562,7 +562,7 @@ def get_minimal_file_set_in_compile_order(self, target_files=None): min_file_set_to_be_compiled = [f for f in max_file_set_to_be_compiled if f in dependency_files] return min_file_set_to_be_compiled - def _get_affected_files(self, target_files, get_depend_func): + def get_affected_files(self, target_files, get_depend_func): """ Get affected files given a list of type SourceFile, if the list is None all files are taken into account diff --git a/vunit/ui/__init__.py b/vunit/ui/__init__.py index dd9975e78..160ba7fd5 100644 --- a/vunit/ui/__init__.py +++ b/vunit/ui/__init__.py @@ -16,7 +16,7 @@ import logging import json import os -from typing import Optional, Set, Union +from typing import Optional, Set, Union, List from pathlib import Path from fnmatch import fnmatch @@ -111,6 +111,22 @@ def from_args( """ return cls(args, vhdl_standard=vhdl_standard) + @staticmethod + def _make_test_filter(args, test_patterns): + "Create test filter function from test patterns." + + def test_filter(name, attribute_names): + keep = any(fnmatch(name, pattern) for pattern in test_patterns) + + if args.with_attributes is not None: + keep = keep and set(args.with_attributes).issubset(attribute_names) + + if args.without_attributes is not None: + keep = keep and set(args.without_attributes).isdisjoint(attribute_names) + return keep + + return test_filter + def __init__( self, args, @@ -125,17 +141,7 @@ def __init__( else: self._printer = COLOR_PRINTER - def test_filter(name, attribute_names): - keep = any(fnmatch(name, pattern) for pattern in args.test_patterns) - - if args.with_attributes is not None: - keep = keep and set(args.with_attributes).issubset(attribute_names) - - if args.without_attributes is not None: - keep = keep and set(args.without_attributes).isdisjoint(attribute_names) - return keep - - self._test_filter = test_filter + self._test_filter = self._make_test_filter(args, args.test_patterns) self._vhdl_standard: VHDLStandard = select_vhdl_standard(vhdl_standard) self._preprocessors = [] # type: ignore @@ -162,6 +168,8 @@ def test_filter(name, attribute_names): self._builtins = Builtins(self, self._vhdl_standard, simulator_class) + self._run_dependent_on: List[Union[str, Path]] = [] + def _create_database(self): """ Create a persistent database to store expensive parse results @@ -736,6 +744,8 @@ def _main(self, post_run): """ Base vunit main function without performing exit """ + if self._run_dependent_on: + self._update_test_filter(self._run_dependent_on) if self._args.export_json is not None: return self._main_export_json(self._args.export_json) @@ -752,6 +762,47 @@ def _main(self, post_run): all_ok = self._main_run(post_run) return all_ok + def _update_test_filter(self, dependency_files): + """ + Update test filter to match testbenches depending on user_files + """ + # Find source file objects corresponding to the provided files. + # Non-existing files, directories and files not added to the + # project are ignored. + dependency_paths = [] + for dependency_file in dependency_files: + if isinstance(dependency_file, str): + dependency_file = Path(dependency_file) + dependency_file = dependency_file.resolve() + if dependency_file.exists() and not dependency_file.is_dir(): + dependency_paths.append(dependency_file) + + project = self._project + dependency_source_files = [ + source_file + for source_file in project.get_source_files_in_order() + if source_file.original_name in dependency_paths + ] + + # Get dependent files, non-testbench files included + dependency_graph = project.create_dependency_graph(True) + dependent_files = project.get_affected_files(dependency_source_files, dependency_graph.get_dependent) + + # Extract testbenches from dependent files and create corresponding test patterns: + # lib_name.tb_name* + test_patterns = [] + for dependent_file in dependent_files: + library_name = dependent_file.library.name + for testbench in self._test_bench_list.get_test_benches_in_library(library_name): + if testbench.design_unit.source_file == dependent_file: + test_patterns.append(f"{library_name}.{testbench.name}*") + + # Update test filter to match test patterns + if isinstance(self._args.test_patterns, list): + test_patterns += self._args.test_patterns + + self._test_filter = self._make_test_filter(self._args, test_patterns) + def _create_simulator_if(self): """ Create new simulator instance @@ -1032,6 +1083,19 @@ def add_json4vhdl(self): """ self._builtins.add("json4vhdl") + def run_dependent(self, source_files: List[Union[str, Path]]) -> None: + """ + Run testbenches depending on the provided source files. + + Test patterns on the command line will add to the depending testbenches. + + :param source_files: List of :class:`str` or :class:`pathlib.Path` items, + each representing the relative or absolute path to + the source file. + :returns: None + """ + self._run_dependent_on = source_files + def get_compile_order(self, source_files=None): """ Get the compile order of all or specific source files and