Coverage for apio/scons/plugin_util.py: 84%
286 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
1# This file is part of the Apio project
2# (C) 2016-present FPGAwars
3# Project authors:
4# * Jesús Arroyo (Jesus89) (2016-2019)
5# * Juan Gonzalez (obijuan) (2019-2024)
6# * The Apio Team (2024-present)
7# License: GPLv2
9"""Helper functions for apio scons plugins."""
11from glob import glob
12import os
13import re
14import subprocess
15from dataclasses import dataclass
16from pathlib import Path
17from rich.table import Table
18from rich import box
19from SCons import Scanner
20from SCons.Builder import Builder
21from SCons.Action import FunctionAction, Action
22from SCons.Node.FS import File
23from SCons.Script.SConscript import SConsEnvironment
24from SCons.Node import NodeList
25from SCons.Node.Alias import Alias
26from apio.scons.apio_env import ApioEnv
27from apio.common.proto.stubs.apio_scons_pb2 import SimParams, ApioTestParams
28from apio.common.common_util import (
29 PROJECT_BUILD_PATH,
30 has_testbench_name,
31 is_source_file,
32 cmd_in_env,
33)
34from apio.common.debug_util import is_debug
35from apio.common.apio_console import cout, ctable, fatal_error
36from apio.common.apio_styles import INFO, BORDER, EMPH1, EMPH2, EMPH3
37from apio.scons import gtkwave_util
38from apio.common.build_report import BuildReport, read_build_report
40TESTBENCH_HINT = "Testbench file names must end with '_tb.v' or '_tb.sv'."
43def map_str_params(str_params: list[str] | None, fmt: str) -> str:
44 """A common function construct a command string snippet from a list
45 of arguments. The function does the following:
46 1. If params arg is None replace it with []
47 2. Drops empty or white space only items.
48 3. Maps the items using the format string which contains exactly one
49 placeholder {}.
50 4. Joins the items with a white space char.
52 For examples, see the unit test at test_scons_util.py.
53 """
54 # -- Replace None with an empty list.
55 if str_params is None:
56 str_params = []
58 # Convert params to stripped strings.
59 str_params = [x.strip() for x in str_params]
61 # Drop the empty params and map the rest.
62 mapped_params = [fmt.format(x) for x in str_params if x]
64 # Join using a single space.
65 return " ".join(mapped_params)
68def map_path_params(path_params: list[Path] | None, fmt: str) -> str:
69 """Same as map_str_params() but accepts a list of Path that is first
70 converted to a string and then passed to map_str_params()"""
71 # -- Replace None with an empty list
72 if path_params is None:
73 path_params = []
75 # -- Convert to a list of strings.
76 str_params: list[str] = []
77 for p in path_params:
78 assert isinstance(p, Path), type(p)
79 str_params.append(str(p))
81 # -- Map the strings.
82 return map_str_params(str_params, fmt)
85def get_constraint_file(apio_env: ApioEnv, file_ext: str) -> str:
86 """Returns the name of the constraint file to use.
88 env is the sconstruction environment.
90 file_ext is a string with the constrained file extension.
91 E.g. ".pcf" for ice40.
93 Returns the file name if found or exit with an error otherwise.
94 """
96 # -- If the user specified a 'constraint-file' in apio.ini then use it.
97 user_specified = apio_env.params.apio_env_params.constraint_file
99 if user_specified:
100 path = Path(user_specified)
101 # -- Path should be relative.
102 if path.is_absolute(): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 fatal_error(
104 f"Constraint file path is not relative: {user_specified}"
105 )
106 # -- Constrain file extension should match the architecture.
107 if path.suffix != file_ext:
108 fatal_error(
109 f"Constraint file should have the extension '{file_ext}': "
110 + f"{user_specified}."
111 )
112 # -- File should not be under _build
113 if PROJECT_BUILD_PATH in path.parents:
114 fatal_error(
115 f"Constraint file should not be under {PROJECT_BUILD_PATH}: "
116 + f"{user_specified}."
117 )
118 # -- Path should not contain '..' to avoid traveling outside of the
119 # -- project and coming back.
120 for part in path.parts:
121 if part == "..":
122 fatal_error(
123 "Constraint file path should not contain '..': "
124 + f"{user_specified}."
125 )
127 # -- Constrain file looks good.
128 return user_specified
130 # -- No user specified constraint file, we will try to look for it
131 # -- in the project tree.
132 glob_files: list[str] = glob(f"**/*{file_ext}", recursive=True)
134 # -- Exclude files that are under _build
135 filtered_files: list[str] = [
136 f for f in glob_files if PROJECT_BUILD_PATH not in Path(f).parents
137 ]
139 # -- Handle by file count.
140 n = len(filtered_files)
142 # -- Case 1: No matching constrain files.
143 if n == 0:
144 fatal_error(f"No constraint file '*{file_ext}' found.")
146 # -- Case 2: Exactly one constrain file found.
147 if n == 1:
148 result = str(filtered_files[0])
149 return result
151 # -- Case 3: Multiple matching constrain files.
152 fatal_error(
153 f"Found {n} constraint files '*{file_ext}' "
154 + "in the project tree, which one to use?",
155 info="Use the apio.ini constraint-file option to specify "
156 + "the desired file.",
157 )
160def verilog_src_scanner(apio_env: ApioEnv) -> Scanner.Base:
161 """Creates and returns a scons Scanner object for scanning verilog
162 files for dependencies.
163 """
164 # A Regex to icestudio propriaetry references for *.list files.
165 # Example:
166 # Text: ' parameter v771499 = "v771499.list"'
167 # Captures: 'v771499.list'
168 icestudio_list_re = re.compile(r"[\n|\s][^\/]?\"(.*\.list?)\"", re.M)
170 # A regex to match a verilog include directive.
171 # Example
172 # Text: `include "apio_testing.vh"
173 # Capture: 'apio_testing.vh'
174 verilog_include_re = re.compile(r'`\s*include\s+["]([^"]+)["]', re.M)
176 # A regex for inclusion via $readmemh()
177 # Example
178 # Test: '$readmemh("my_data.hex", State_buff);'
179 # Capture: 'my_data.hex'
180 readmemh_reference_re = re.compile(
181 r"\$readmemh\([\'\"]([^\'\"]+)[\'\"]", re.M
182 )
184 # -- List of required and optional files that may require a rebuild if
185 # -- changed.
186 core_dependencies = [
187 "apio.ini",
188 "boards.jsonc",
189 "fpgas.jsonc",
190 "programmers.jsonc",
191 ]
193 def verilog_src_scanner_func(
194 file_node: File, env: SConsEnvironment, ignored_path
195 ) -> list[str]:
196 """Given a [System]Verilog file, scan it and return a list of
197 references to other files it depends on. It's not require to report
198 dependency on another source file in the project since scons loads
199 anyway all the source files in the project.
201 Returns a list of files. Dependencies that don't have an existing
202 file are ignored and not returned. This is to avoid references in
203 commented out code to break scons dependencies.
204 """
205 _ = env # Unused
207 # Sanity check. Should be called only to scan verilog files. If
208 # this fails, this is a programming error rather than a user error.
209 if not is_source_file(file_node.name): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 fatal_error(f"'{file_node.name}' is not a source file.")
212 # Get the directory of the file, relative to the project root which is
213 # the current working directory. This value is equals to "." if the
214 # file is in the project root.
215 file_dir: str = file_node.get_dir().get_path()
217 # Prepare an empty set of dependencies.
218 candidates_raw_set = set()
220 # Read the file. This returns [] if the file doesn't exist.
221 file_content = file_node.get_text_contents()
223 # Get verilog includes references.
224 candidates_raw_set.update(verilog_include_re.findall(file_content))
226 # Get $readmemh() function references.
227 candidates_raw_set.update(readmemh_reference_re.findall(file_content))
229 # Get IceStudio references.
230 candidates_raw_set.update(icestudio_list_re.findall(file_content))
232 # Since we don't know if the dependency's path is relative to the file
233 # location or the project root, we try both. We prefer to have high
234 # recall of dependencies of high precision, risking at most unnecessary
235 # rebuilds.
236 candidates_set = candidates_raw_set.copy()
237 # If the file is not in the project dir, add a dependency also relative
238 # to the project dir.
239 if file_dir != ".":
240 for raw_candidate in candidates_raw_set:
241 candidate: str = os.path.join(file_dir, raw_candidate)
242 candidates_set.add(candidate)
244 # Add the core dependencies. They are always relative to the project
245 # root.
246 candidates_set.update(core_dependencies)
248 # Filter out candidates that don't have a matching files to prevert
249 # breaking the build. This handle for example the case where the
250 # file references is in a comment or non reachable code.
251 # See also https://stackoverflow.com/q/79302552/15038713
252 dependencies = []
253 for dependency in candidates_set:
254 if Path(dependency).exists():
255 dependencies.append(dependency)
256 elif is_debug(1): 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
257 cout(
258 f"Dependency candidate {dependency} does not exist, "
259 "dropping."
260 )
262 # Sort the strings for determinism.
263 dependencies = sorted(list(dependencies))
265 # Debug info.
266 if is_debug(1): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 cout(f"Dependencies of {file_node}:", style=EMPH2)
268 for dependency in dependencies:
269 cout(f" {dependency}", style=EMPH2)
271 # All done
272 return apio_env.scons_env.File(dependencies)
274 return apio_env.scons_env.Scanner(function=verilog_src_scanner_func)
277def verilator_lint_action(
278 apio_env: ApioEnv,
279 *,
280 extra_params: list[str] | None = None,
281 lib_dirs: list[Path] | None = None,
282 lib_files: list[Path] | None = None,
283) -> list[FunctionAction | str]:
284 # -> list[
285 # Callable[
286 # [
287 # list[File],
288 # list[Alias],
289 # SConsEnvironment,
290 # ],
291 # None,
292 # ]
293 # | str,
294 # ]:
295 """Construct an verilator scons action.
296 * extra_params: Optional additional arguments.
297 * libs_dirs: Optional directories for include search.
298 * lib_files: Optional additional files to include.
299 Returns an action in a form of a list with two steps, a function to call
300 and a string command.
301 """
303 # -- Sanity checks
304 assert apio_env.targeting_one_of("lint")
305 assert apio_env.params.target.HasField("lint")
307 # -- Keep short references.
308 params = apio_env.params
309 lint_params = params.target.lint
311 # -- Determine if linting the entire project or just a few files,
312 lint_whole_project = not lint_params.file_names
314 # -- Determine if using a vlt file. We use it only when linting a whole
315 # -- project and --novlt was not specified.
316 using_vlt = lint_whole_project and (not lint_params.novlt)
318 # -- Determine the top module.
319 if lint_params.top_module:
320 # -- Case 1: Top module was specified in the command line.
321 top_module = lint_params.top_module
322 elif lint_whole_project:
323 # -- Case 2: Linting the entire project, use top module from apio.ini,
324 top_module = params.apio_env_params.top_module
325 else:
326 # -- Linting only a few files and top module was not specified.
327 top_module = None
329 print(f"{params.apio_env_params.verilator_extra_options=}")
330 # -- Construct the action
331 action = (
332 "verilator_bin --lint-only --quiet --bbox-unsup --timing "
333 "-Wno-TIMESCALEMOD -Wno-MULTITOP {0} {1} -DAPIO_SIM=0 "
334 "{2} {3} {4} {5} {6} {7} {8} $SOURCES"
335 ).format(
336 "" if lint_params.nosynth else "-DSYNTHESIZE",
337 "" if lint_whole_project else "-Wno-MODMISSING",
338 " ".join(params.apio_env_params.verilator_extra_options),
339 f"--top-module {top_module}" if top_module else "",
340 get_define_flags(apio_env),
341 map_str_params(extra_params, "{}"),
342 (map_path_params(lib_dirs, '-I"{}"') if lint_whole_project else ""),
343 apio_env.target + ".vlt" if using_vlt else "",
344 (map_path_params(lib_files, '"{}"') if lint_whole_project else ""),
345 )
347 return [
348 source_files_issue_scanner_action(),
349 str(action),
350 ]
353@dataclass(frozen=True)
354class TestbenchInfo:
355 """Testbench simulation parameters, used by apio sim and apio test
356 commands."""
358 testbench_path: str # The relative testbench file path.
359 build_testbench_name: str # testbench_name prefixed by build dir.
360 srcs: list[str] # List of source files to compile.
362 @property
363 def testbench_name(self) -> str:
364 """The testbench path without the file extension."""
365 return basename(self.testbench_path)
368def detached_action(apio_env: ApioEnv, cmd: list[str]) -> Action:
369 """
370 Launch the given command, given as a list of tokens, in a detached
371 (non blocking) mode.
372 """
374 def action_func(
375 target: list[Alias], source: list[File], env: SConsEnvironment
376 ):
377 """A call back function to perform the detached command invocation."""
379 # -- Make the linter happy
380 # pylint: disable=consider-using-with
381 _ = (target, source, env)
383 # -- NOTE: To debug these Popen operations, comment out the stdout=
384 # -- and stderr= lines to see the output and error messages from the
385 # -- commands.
387 tools_env = apio_env.scons_env["ENV"]
389 # -- Handle the case of Window.
390 if apio_env.apio_platform.is_windows:
391 detached_flag = getattr(subprocess, "DETACHED_PROCESS", 0)
392 group_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
393 creationflags = detached_flag | group_flag
395 subprocess.Popen(
396 cmd_in_env(cmd, tools_env),
397 creationflags=creationflags,
398 stdout=subprocess.DEVNULL,
399 stderr=subprocess.DEVNULL,
400 close_fds=True,
401 shell=False,
402 env=tools_env,
403 )
404 return 0
406 # -- Handle the rest (macOS and Linux)
407 subprocess.Popen(
408 cmd_in_env(cmd, tools_env),
409 stdout=subprocess.DEVNULL,
410 stderr=subprocess.DEVNULL,
411 close_fds=True,
412 start_new_session=True,
413 shell=False,
414 env=tools_env,
415 )
416 return 0
418 # -- Create the command display string that will be shown to the user.
419 cmd_str: str = subprocess.list2cmdline(cmd)
420 display_str: str = "[detached] " + cmd_str
422 # -- Create the action and return
423 action = Action(action_func, display_str)
424 return action
427def gtkwave_target(
428 apio_env: ApioEnv,
429 target_name: str, # always 'sim'
430 vcd_file_target: NodeList,
431 testbench_info: TestbenchInfo,
432 sim_params: SimParams,
433 gtkwave_extra_options: list[str] | None,
434) -> list[Alias]:
435 """Construct a target to launch the QTWave signal viewer.
436 vcd_file_target is the simulator target that generated the vcd file
437 with the signals. Returns the new targets.
438 """
440 # pylint: disable=too-many-arguments
441 # pylint: disable=too-many-positional-arguments
443 # -- Construct the list of actions.
444 actions = []
446 # -- If needed, generate default .gtkw file to make sure the top level
447 # -- signals are shown by default.
448 gtkw_path: str = testbench_info.testbench_name + ".gtkw"
449 vcd_path = str(vcd_file_target[0])
451 def create_default_gtkw_file(
452 target: list[Alias], source: list[File], env: SConsEnvironment
453 ):
454 """The action function to generate the default .gtkw file."""
455 _ = (target, source, env) # Unused.
456 cout(f"Generating default {gtkw_path}")
457 gtkwave_util.create_gtkwave_file(
458 testbench_info.testbench_path, vcd_path, gtkw_path
459 )
461 if gtkwave_util.is_user_gtkw_file(gtkw_path):
462 cout(f"Found user saved {gtkw_path}")
463 else:
464 actions.append(Action(create_default_gtkw_file, strfunction=None))
466 # -- Skip or execute gtkwave.
467 if sim_params.no_gtkwave: 467 ↛ 483line 467 didn't jump to line 483 because the condition on line 467 was always true
468 # -- User asked to skip gtkwave. The '@' suppresses the printing
469 # -- of the echo command itself.
470 actions.append(
471 "@echo 'Flag --no-gtkwave was found, skipping GTKWave.'"
472 )
474 else:
475 # -- Normal case, invoking gtkwave.
477 # -- On windows we need to setup the cache. This could be done once
478 # -- when the oss-cad-suite is installed but since we currently don't
479 # -- have a package setup mechanism, we do it here on each invocation.
480 # -- The time penalty is negligible.
481 # -- With the stock oss-cad-suite windows package, this is done in the
482 # -- environment.bat script.
483 if apio_env.apio_platform.is_windows:
484 actions.append("gdk-pixbuf-query-loaders --update-cache")
486 # -- The actual wave viewer command.
487 gtkwave_cmd = ["gtkwave"]
488 # -- NOTE: Users can override these rcvars by adding the desired
489 # -- rcvar options in apio.ini gtkwave-extra-options which will win
490 # -- since they will appear later in the command line.
491 gtkwave_cmd.append("--rcvar=do_initial_zoom_fit 1")
492 if gtkwave_extra_options:
493 gtkwave_cmd.extend(gtkwave_extra_options)
494 gtkwave_cmd.extend([vcd_path, gtkw_path])
496 # -- Handle the case where gtkwave is run as a detached app, not
497 # -- waiting for it to close and not showing its output.
498 if sim_params.detach_gtkwave:
499 gtkwave_action = detached_action(apio_env, gtkwave_cmd)
500 else:
501 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd)
503 actions.append(gtkwave_action)
505 # -- Define a target with the action(s) we created.
506 target = apio_env.add_alias(
507 target_name,
508 source=vcd_file_target,
509 action=actions,
510 always_build=True,
511 )
513 return target
516def check_valid_testbench_name(testbench: str) -> None:
517 """Check if a testbench name is valid. If not, print an error message
518 and exit."""
519 if not is_source_file(testbench) or not has_testbench_name(testbench): 519 ↛ 520line 519 didn't jump to line 520 because the condition on line 519 was never true
520 fatal_error(
521 f"'{testbench}' is not a valid testbench file name.",
522 info=TESTBENCH_HINT,
523 )
526def get_apio_sim_testbench_info(
527 apio_env: ApioEnv,
528 sim_params: SimParams,
529 synth_srcs: list[str],
530 test_srcs: list[str],
531) -> TestbenchInfo:
532 """Returns a SimulationConfig for a sim command. 'testbench' is
533 an optional testbench file name. 'synth_srcs' and 'test_srcs' are the
534 all the project's synth and testbench files found in the project as
535 returned by get_project_source_files()."""
537 # -- Handle the testbench file selection. The end result is a single
538 # -- testbench file name in testbench that we simulate, or a fatal error.
539 if sim_params.testbench_path:
540 # -- Case 1 - Testbench file name is specified in the command or
541 # -- apio.ini. Fatal error if invalid.
542 check_valid_testbench_name(sim_params.testbench_path)
543 testbench = sim_params.testbench_path
544 elif len(test_srcs) == 0: 544 ↛ 547line 544 didn't jump to line 547 because the condition on line 544 was never true
545 # -- Case 2 Testbench name was not specified and no testbench files
546 # -- were found in the project.
547 fatal_error(
548 "No testbench files found in the project.",
549 info=TESTBENCH_HINT,
550 )
551 elif len(test_srcs) == 1: 551 ↛ 559line 551 didn't jump to line 559 because the condition on line 551 was always true
552 # -- Case 3 Testbench name was not specified but there is exactly
553 # -- one in the project.
554 testbench = test_srcs[0]
555 cout(f"Found testbench file {testbench}", style=EMPH1)
556 else:
557 # -- Case 4 Testbench name was not specified and there are multiple
558 # -- testbench files in the project.
559 fatal_error(
560 "Multiple testbench files found in the project.",
561 info=[
562 "Please specify the testbench file name in the command ",
563 "or specify the 'default-testbench' option in apio.ini.",
564 ],
565 )
567 # -- This should not happen. If it does, it's a programming error.
568 assert testbench, "get_sim_config(): Missing testbench file name"
570 # -- Construct a SimulationParams with all the synth files + the
571 # -- testbench file.
572 testbench_name = basename(testbench)
573 build_testbench_name = str(apio_env.env_build_path / testbench_name)
574 srcs = synth_srcs + [testbench]
575 return TestbenchInfo(testbench, build_testbench_name, srcs)
578def get_apio_test_testbenches_infos(
579 apio_env: ApioEnv,
580 test_params: ApioTestParams,
581 synth_srcs: list[str],
582 test_srcs: list[str],
583) -> list[TestbenchInfo]:
584 """Return a list of SimulationConfigs for each of the testbenches that
585 need to be run for a 'apio test' command. If testbench is empty,
586 all the testbenches in test_srcs will be tested. Otherwise, only the
587 testbench in testbench will be tested. synth_srcs and test_srcs are
588 source and test file lists as returned by get_project_source_files()."""
589 # List of testbenches to be tested.
591 # -- Handle the testbench files selection. The end result is a list of one
592 # -- or more testbench file names in testbenches that we test.
593 if test_params.testbench_path:
594 # -- Case 1 - a testbench file name is specified in the command or
595 # -- apio.ini. Fatal error if invalid.
596 check_valid_testbench_name(test_params.testbench_path)
597 testbenches = [test_params.testbench_path]
598 elif len(test_srcs) == 0: 598 ↛ 601line 598 didn't jump to line 601 because the condition on line 598 was never true
599 # -- Case 2 - Testbench file name was not specified and there are no
600 # -- testbench files in the project.
601 fatal_error(
602 "No testbench files found in the project.",
603 info=TESTBENCH_HINT,
604 )
605 elif test_params.default_option:
606 # -- Case 3: using --default option with no default testbench
607 # -- specified in apio.ini. If we have exacly one testbench that
608 # -- this is the default testbench, otherwise this is an error.
609 if len(test_srcs) == 1: 609 ↛ 612line 609 didn't jump to line 612 because the condition on line 609 was always true
610 testbenches = [test_srcs[0]]
611 else:
612 fatal_error(
613 "Multiple testbench files found in the project.",
614 info=[
615 "To test only a single testbench, replace --default "
616 + "with the testbench",
617 "file path, or specify the 'default-testbench' "
618 + "option in apio.ini.",
619 ],
620 )
621 else:
622 # -- Case 4 - Testbench file name was not specified but there are one
623 # -- or more testbench files in the project.
624 testbenches = test_srcs
626 # -- If this fails, it's a programming error.
627 assert testbenches, "get_tests_configs(): no testbenches"
629 # Construct a config for each testbench.
630 configs = []
631 for tb in testbenches:
632 testbench_name = basename(tb)
633 build_testbench_name = str(apio_env.env_build_path / testbench_name)
634 srcs = synth_srcs + [tb]
635 configs.append(TestbenchInfo(tb, build_testbench_name, srcs))
637 return configs
640def announce_testbench_action() -> FunctionAction:
641 """Returns an action that prints a title with the testbench name."""
643 def announce_testbench(
644 target: list[Alias],
645 source: list[File],
646 env: SConsEnvironment,
647 ):
648 """The action function."""
649 _ = (target, env) # Unused
651 # -- We expect to find exactly one testbench.
652 testbenches = [
653 file
654 for file in source
655 if (is_source_file(file.name) and has_testbench_name(file.name))
656 ]
657 assert len(testbenches) == 1, testbenches
659 # -- Announce it.
660 cout()
661 cout(f"Testbench {testbenches[0]}", style=EMPH3)
663 # -- Run the action but don't announce the action.
664 return Action(
665 announce_testbench,
666 strfunction=None,
667 )
670def source_files_issue_scanner_action() -> FunctionAction:
671 """Returns a SCons action that scans the source files and print
672 error or warning messages about issues it finds."""
674 # A regex to identify "$dumpfile(" in testbenches.
675 testbench_dumpfile_re = re.compile(r"[$]dumpfile\s*[(]")
677 def report_source_files_issues(
678 target: list[Alias],
679 source: list[File],
680 env: SConsEnvironment,
681 ):
682 """The scanner function."""
684 _ = (target, env) # Unused
686 for file in source:
688 # -- For now we report issues only in testbenches so skip
689 # -- otherwise.
690 if not is_source_file(file.name) or not has_testbench_name(
691 file.name
692 ):
693 continue
695 # -- Read the testbench file text.
696 file_text = file.get_text_contents()
698 # -- if contains $dumpfile, it's a fatal error. Apio sets the
699 # -- default location of the testbenches output .vcd file.
700 if testbench_dumpfile_re.findall(file_text): 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true
701 fatal_error(
702 f"The testbench file '{file.name}' contains '$dumpfile'.",
703 info=[
704 "Do not use $dumpfile(...) in your Apio testbenches.",
705 "Let Apio configure automatically the proper "
706 + "locations of the dump files.",
707 ],
708 )
710 # -- Run the action but don't announce the action. We will print
711 # -- ourselves in report_source_files_issues.
712 return Action(
713 report_source_files_issues,
714 strfunction=None,
715 )
718def _print_pnr_report(
719 build_report: BuildReport,
720 verbose: bool,
721) -> None:
722 """Emit a user friendly report from build report."""
724 # -- Determine table title.
725 title = "All FPGA resources" if verbose else "Used FPGA Resource"
727 # -- Utilization table
728 table = Table(
729 show_header=True,
730 show_lines=False,
731 box=box.SQUARE,
732 border_style=BORDER,
733 title=title,
734 title_justify="left",
735 padding=(0, 2),
736 )
738 # -- Add columns.
739 table.add_column("RESOURCE", no_wrap=True)
740 table.add_column("USED", no_wrap=True, justify="right")
741 table.add_column("TOTAL", no_wrap=True, justify="right")
742 table.add_column("USED%", no_wrap=True, justify="right")
744 # -- Add rows
745 for res in build_report.resources:
746 # -- By default we skip unused resources
747 if not res.used and not verbose:
748 continue
750 used_str = f"{res.used} " if res.used else ""
751 available_str = f"{res.available} "
752 if not res.used: 752 ↛ 753line 752 didn't jump to line 753 because the condition on line 752 was never true
753 percents_str = ""
754 elif res.percentage is None:
755 percents_str = "-- "
756 else:
757 percents_str = f"{int(res.percentage)}% "
758 style = EMPH3 if res.used > 0 else None
759 table.add_row(
760 res.name, used_str, available_str, percents_str, style=style
761 )
763 # -- Render the utilization table table
764 cout()
765 ctable(table)
767 # -- Clocks table, if there is at least one clock.
768 if len(build_report.clocks) > 0:
770 table = Table(
771 show_header=True,
772 show_lines=True,
773 box=box.SQUARE,
774 border_style=BORDER,
775 title="Clock Information",
776 title_justify="left",
777 padding=(0, 2),
778 )
780 # -- Add columns
781 table.add_column("CLOCK", no_wrap=True)
782 table.add_column(
783 "MAX SPEED [Mhz]", no_wrap=True, justify="right", style=EMPH3
784 )
786 # -- Add rows.
787 for clk in build_report.clocks:
788 table.add_row(clk.name, f"{clk.fmax_mhz:.2f}")
790 # -- Render the clocks table
791 cout()
792 ctable(table)
794 # -- Print hints.
795 cout("")
796 if len(build_report.clocks) == 0:
797 cout("No clocks were found in the design.", style=INFO)
799 if not verbose:
800 cout("Use '--verbose' for additional details.", style=INFO)
803def report_action(verbose: bool) -> FunctionAction:
804 """Returns a SCons action to format and print the PNR reort from the
805 PNR json report file. Used by the 'apio report' command.
806 'script_id' identifies the calling SConstruct script and 'verbose'
807 indicates if the --verbose flag was invoked."""
809 def print_pnr_report(
810 target: list[Alias],
811 source: list[File],
812 env: SConsEnvironment,
813 ):
814 """Action function. Loads the pnr json report and print in a user
815 friendly way."""
816 _ = (target, env) # Unused
817 pnr_json_file: File = source[0]
818 pnr_json_path: Path = Path(pnr_json_file.get_path())
819 build_report: BuildReport = read_build_report(pnr_json_path)
820 _print_pnr_report(build_report, verbose)
822 return Action(
823 print_pnr_report,
824 "Formatting pnr report.",
825 )
828def get_programmer_cmd(apio_env: ApioEnv) -> str:
829 """Return the programmer command as derived from the scons "prog"
830 arg."""
832 # Should be called only if scons paramsm has 'upload' target parmas.
833 params = apio_env.params
834 assert params.target.HasField("upload"), params
836 # Get the programer command template arg.
837 programmer_cmd = params.target.upload.programmer_cmd
838 assert programmer_cmd, params
840 # -- [NOTE] Generally speaking we would expect the command to include
841 # -- $SOURCE for the binary file path but since we allow custom commands
842 # -- using apio.ini's 'programmer-cmd' option, we don't check for it here.
844 return programmer_cmd
847def get_define_flags(apio_env: ApioEnv) -> str:
848 """Return a string with the -D flags for the verilog defines. Returns
849 an empty string if there are no defines."""
850 flags: list[str] = []
851 for define in apio_env.params.apio_env_params.defines:
852 flags.append("-D" + define)
854 return " ".join(flags)
857def iverilog_action(
858 apio_env: ApioEnv,
859 *,
860 verbose: bool,
861 is_interactive: bool,
862 extra_params: list[str] | None = None,
863 lib_dirs: list[Path] | None = None,
864 lib_files: list[Path] | None = None,
865) -> str:
866 """Construct an iverilog scons action string.
867 * env: Rhe scons environment.
868 * verbose: IVerilog will show extra info.
869 * is_interactive: True for apio sim, False otherwise.
870 * extra_params: Optional list of additional IVerilog params.
871 * lib_dirs: Optional list of dir paths to include.
872 * lib_files: Optional list of library files to compile.
873 *
874 * Returns the scons action string for the IVerilog command.
875 """
877 # pylint: disable=too-many-arguments
879 # -- Construct the action string.
880 # -- The -g2012 is for system-verilog support.
881 action = (
882 "iverilog -g2012 {0} -o $TARGET {1} {2} {3} {4} {5} $SOURCES"
883 ).format(
884 "-v" if verbose else "",
885 get_define_flags(apio_env),
886 f"-DAPIO_SIM={int(is_interactive)}",
887 map_str_params(extra_params, "{}"),
888 map_path_params(lib_dirs, '-I"{}"'),
889 map_path_params(lib_files, '"{}"'),
890 )
892 return action
895def basename(file_name: str) -> str:
896 """Given a file name, returns it with the extension removed."""
897 result, _ = os.path.splitext(file_name)
898 return result
901def make_verilator_config_builder(
902 lib_path: Path, rules_to_suppress: list[str]
903) -> Builder:
904 """Create a scons Builder that writes a verilator config file
905 (hardware.vlt) that suppresses warnings in the lib directory.
906 Rules_to_suppress is a list of Verilator rules that should be suppressed
907 for the given lib_path.
908 """
909 assert isinstance(lib_path, Path), lib_path
911 # -- Construct a glob of all library files.
912 glob_path = str(lib_path / "*")
914 # -- Escape for windows. A single backslash is converted into two.
915 glob_str = str(glob_path).replace("\\", "\\\\")
917 # -- Generate the files lines. We suppress a union of all the errors we
918 # -- encountered in all the architectures.
919 lines = ["`verilator_config"]
920 for rule in rules_to_suppress:
921 lines.append(f'lint_off -rule {rule} -file "{glob_str}"')
923 # -- Join the lines into text.
924 text = "\n".join(lines) + "\n"
926 def verilator_config_func(target, source, env):
927 """Creates a verilator .vlt config files."""
928 _ = (source, env) # Unused
929 with open(target[0].get_path(), "w", encoding="utf-8") as target_file:
930 target_file.write(text)
931 return 0
933 return Builder(
934 action=Action(
935 verilator_config_func, "Creating verilator config file."
936 ),
937 suffix=".vlt",
938 )