Coverage for apio/scons/plugin_util.py: 84%
283 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
1# -*- coding: utf-8 -*-
2# -- This file is part of the Apio project
3# -- (C) 2016-2018 FPGAwars
4# -- Author Jesús Arroyo
5# -- License GPLv2
6# -- Derived from:
7# ---- Platformio project
8# ---- (C) 2014-2016 Ivan Kravets <me@ikravets.com>
9# ---- License Apache v2
10"""Helper functions for apio scons plugins."""
12from glob import glob
13import os
14import re
15import subprocess
16from dataclasses import dataclass
17from pathlib import Path
18from rich.table import Table
19from rich import box
20from SCons import Scanner
21from SCons.Builder import Builder
22from SCons.Action import FunctionAction, Action
23from SCons.Node.FS import File
24from SCons.Script.SConscript import SConsEnvironment
25from SCons.Node import NodeList
26from SCons.Node.Alias import Alias
27from apio.scons.apio_env import ApioEnv
28from apio.common.proto.apio_scons_pb2 import SimParams, ApioTestParams
29from apio.common.common_util import (
30 PROJECT_BUILD_PATH,
31 has_testbench_name,
32 is_source_file,
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(api_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 # -- Handle the case of Window.
388 if api_env.is_windows:
389 detached_flag = getattr(subprocess, "DETACHED_PROCESS", 0)
390 group_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
391 creationflags = detached_flag | group_flag
393 subprocess.Popen(
394 cmd,
395 creationflags=creationflags,
396 stdout=subprocess.DEVNULL,
397 stderr=subprocess.DEVNULL,
398 close_fds=True,
399 shell=False,
400 )
401 return 0
403 # -- Handle the rest (macOS and Linux)
404 subprocess.Popen(
405 cmd,
406 stdout=subprocess.DEVNULL,
407 stderr=subprocess.DEVNULL,
408 close_fds=True,
409 start_new_session=True,
410 shell=False,
411 )
412 return 0
414 # -- Create the command display string that will be shown to the user.
415 cmd_str: str = subprocess.list2cmdline(cmd)
416 display_str: str = "[detached] " + cmd_str
418 # -- Create the action and return
419 action = Action(action_func, display_str)
420 return action
423def gtkwave_target(
424 apio_env: ApioEnv,
425 target_name: str, # always 'sim'
426 vcd_file_target: NodeList,
427 testbench_info: TestbenchInfo,
428 sim_params: SimParams,
429 gtkwave_extra_options: list[str] | None,
430) -> list[Alias]:
431 """Construct a target to launch the QTWave signal viewer.
432 vcd_file_target is the simulator target that generated the vcd file
433 with the signals. Returns the new targets.
434 """
436 # pylint: disable=too-many-arguments
437 # pylint: disable=too-many-positional-arguments
439 # -- Construct the list of actions.
440 actions = []
442 # -- If needed, generate default .gtkw file to make sure the top level
443 # -- signals are shown by default.
444 gtkw_path: str = testbench_info.testbench_name + ".gtkw"
445 vcd_path = str(vcd_file_target[0])
447 def create_default_gtkw_file(
448 target: list[Alias], source: list[File], env: SConsEnvironment
449 ):
450 """The action function to generate the default .gtkw file."""
451 _ = (target, source, env) # Unused.
452 cout(f"Generating default {gtkw_path}")
453 gtkwave_util.create_gtkwave_file(
454 testbench_info.testbench_path, vcd_path, gtkw_path
455 )
457 if gtkwave_util.is_user_gtkw_file(gtkw_path):
458 cout(f"Found user saved {gtkw_path}")
459 else:
460 actions.append(Action(create_default_gtkw_file, strfunction=None))
462 # -- Skip or execute gtkwave.
463 if sim_params.no_gtkwave: 463 ↛ 479line 463 didn't jump to line 479 because the condition on line 463 was always true
464 # -- User asked to skip gtkwave. The '@' suppresses the printing
465 # -- of the echo command itself.
466 actions.append(
467 "@echo 'Flag --no-gtkwave was found, skipping GTKWave.'"
468 )
470 else:
471 # -- Normal case, invoking gtkwave.
473 # -- On windows we need to setup the cache. This could be done once
474 # -- when the oss-cad-suite is installed but since we currently don't
475 # -- have a package setup mechanism, we do it here on each invocation.
476 # -- The time penalty is negligible.
477 # -- With the stock oss-cad-suite windows package, this is done in the
478 # -- environment.bat script.
479 if apio_env.is_windows:
480 actions.append("gdk-pixbuf-query-loaders --update-cache")
482 # -- The actual wave viewer command.
483 gtkwave_cmd = ["gtkwave"]
484 # -- NOTE: Users can override these rcvars by adding the desired
485 # -- rcvar options in apio.ini gtkwave-extra-options which will win
486 # -- since they will appear later in the command line.
487 gtkwave_cmd.append("--rcvar=splash_disable on")
488 gtkwave_cmd.append("--rcvar=do_initial_zoom_fit 1")
489 if gtkwave_extra_options:
490 gtkwave_cmd.extend(gtkwave_extra_options)
491 gtkwave_cmd.extend([vcd_path, gtkw_path])
493 # -- Handle the case where gtkwave is run as a detached app, not
494 # -- waiting for it to close and not showing its output.
495 if sim_params.detach_gtkwave:
496 gtkwave_action = detached_action(apio_env, gtkwave_cmd)
497 else:
498 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd)
500 actions.append(gtkwave_action)
502 # -- Define a target with the action(s) we created.
503 target = apio_env.add_alias(
504 target_name,
505 source=vcd_file_target,
506 action=actions,
507 always_build=True,
508 )
510 return target
513def check_valid_testbench_name(testbench: str) -> None:
514 """Check if a testbench name is valid. If not, print an error message
515 and exit."""
516 if not is_source_file(testbench) or not has_testbench_name(testbench): 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 fatal_error(
518 f"'{testbench}' is not a valid testbench file name.",
519 info=TESTBENCH_HINT,
520 )
523def get_apio_sim_testbench_info(
524 apio_env: ApioEnv,
525 sim_params: SimParams,
526 synth_srcs: list[str],
527 test_srcs: list[str],
528) -> TestbenchInfo:
529 """Returns a SimulationConfig for a sim command. 'testbench' is
530 an optional testbench file name. 'synth_srcs' and 'test_srcs' are the
531 all the project's synth and testbench files found in the project as
532 returned by get_project_source_files()."""
534 # -- Handle the testbench file selection. The end result is a single
535 # -- testbench file name in testbench that we simulate, or a fatal error.
536 if sim_params.testbench_path:
537 # -- Case 1 - Testbench file name is specified in the command or
538 # -- apio.ini. Fatal error if invalid.
539 check_valid_testbench_name(sim_params.testbench_path)
540 testbench = sim_params.testbench_path
541 elif len(test_srcs) == 0: 541 ↛ 544line 541 didn't jump to line 544 because the condition on line 541 was never true
542 # -- Case 2 Testbench name was not specified and no testbench files
543 # -- were found in the project.
544 fatal_error(
545 "No testbench files found in the project.",
546 info=TESTBENCH_HINT,
547 )
548 elif len(test_srcs) == 1: 548 ↛ 556line 548 didn't jump to line 556 because the condition on line 548 was always true
549 # -- Case 3 Testbench name was not specified but there is exactly
550 # -- one in the project.
551 testbench = test_srcs[0]
552 cout(f"Found testbench file {testbench}", style=EMPH1)
553 else:
554 # -- Case 4 Testbench name was not specified and there are multiple
555 # -- testbench files in the project.
556 fatal_error(
557 "Multiple testbench files found in the project.",
558 info=[
559 "Please specify the testbench file name in the command ",
560 "or specify the 'default-testbench' option in apio.ini.",
561 ],
562 )
564 # -- This should not happen. If it does, it's a programming error.
565 assert testbench, "get_sim_config(): Missing testbench file name"
567 # -- Construct a SimulationParams with all the synth files + the
568 # -- testbench file.
569 testbench_name = basename(testbench)
570 build_testbench_name = str(apio_env.env_build_path / testbench_name)
571 srcs = synth_srcs + [testbench]
572 return TestbenchInfo(testbench, build_testbench_name, srcs)
575def get_apio_test_testbenches_infos(
576 apio_env: ApioEnv,
577 test_params: ApioTestParams,
578 synth_srcs: list[str],
579 test_srcs: list[str],
580) -> list[TestbenchInfo]:
581 """Return a list of SimulationConfigs for each of the testbenches that
582 need to be run for a 'apio test' command. If testbench is empty,
583 all the testbenches in test_srcs will be tested. Otherwise, only the
584 testbench in testbench will be tested. synth_srcs and test_srcs are
585 source and test file lists as returned by get_project_source_files()."""
586 # List of testbenches to be tested.
588 # -- Handle the testbench files selection. The end result is a list of one
589 # -- or more testbench file names in testbenches that we test.
590 if test_params.testbench_path:
591 # -- Case 1 - a testbench file name is specified in the command or
592 # -- apio.ini. Fatal error if invalid.
593 check_valid_testbench_name(test_params.testbench_path)
594 testbenches = [test_params.testbench_path]
595 elif len(test_srcs) == 0: 595 ↛ 598line 595 didn't jump to line 598 because the condition on line 595 was never true
596 # -- Case 2 - Testbench file name was not specified and there are no
597 # -- testbench files in the project.
598 fatal_error(
599 "No testbench files found in the project.",
600 info=TESTBENCH_HINT,
601 )
602 elif test_params.default_option:
603 # -- Case 3: using --default option with no default testbench
604 # -- specified in apio.ini. If we have exacly one testbench that
605 # -- this is the default testbench, otherwise this is an error.
606 if len(test_srcs) == 1: 606 ↛ 609line 606 didn't jump to line 609 because the condition on line 606 was always true
607 testbenches = [test_srcs[0]]
608 else:
609 fatal_error(
610 "Multiple testbench files found in the project.",
611 info=[
612 "To test only a single testbench, replace --default "
613 + "with the testbench",
614 "file path, or specify the 'default-testbench' "
615 + "option in apio.ini.",
616 ],
617 )
618 else:
619 # -- Case 4 - Testbench file name was not specified but there are one
620 # -- or more testbench files in the project.
621 testbenches = test_srcs
623 # -- If this fails, it's a programming error.
624 assert testbenches, "get_tests_configs(): no testbenches"
626 # Construct a config for each testbench.
627 configs = []
628 for tb in testbenches:
629 testbench_name = basename(tb)
630 build_testbench_name = str(apio_env.env_build_path / testbench_name)
631 srcs = synth_srcs + [tb]
632 configs.append(TestbenchInfo(tb, build_testbench_name, srcs))
634 return configs
637def announce_testbench_action() -> FunctionAction:
638 """Returns an action that prints a title with the testbench name."""
640 def announce_testbench(
641 target: list[Alias],
642 source: list[File],
643 env: SConsEnvironment,
644 ):
645 """The action function."""
646 _ = (target, env) # Unused
648 # -- We expect to find exactly one testbench.
649 testbenches = [
650 file
651 for file in source
652 if (is_source_file(file.name) and has_testbench_name(file.name))
653 ]
654 assert len(testbenches) == 1, testbenches
656 # -- Announce it.
657 cout()
658 cout(f"Testbench {testbenches[0]}", style=EMPH3)
660 # -- Run the action but don't announce the action.
661 return Action(
662 announce_testbench,
663 strfunction=None,
664 )
667def source_files_issue_scanner_action() -> FunctionAction:
668 """Returns a SCons action that scans the source files and print
669 error or warning messages about issues it finds."""
671 # A regex to identify "$dumpfile(" in testbenches.
672 testbench_dumpfile_re = re.compile(r"[$]dumpfile\s*[(]")
674 def report_source_files_issues(
675 target: list[Alias],
676 source: list[File],
677 env: SConsEnvironment,
678 ):
679 """The scanner function."""
681 _ = (target, env) # Unused
683 for file in source:
685 # -- For now we report issues only in testbenches so skip
686 # -- otherwise.
687 if not is_source_file(file.name) or not has_testbench_name(
688 file.name
689 ):
690 continue
692 # -- Read the testbench file text.
693 file_text = file.get_text_contents()
695 # -- if contains $dumpfile, it's a fatal error. Apio sets the
696 # -- default location of the testbenches output .vcd file.
697 if testbench_dumpfile_re.findall(file_text): 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true
698 fatal_error(
699 f"The testbench file '{file.name}' contains '$dumpfile'.",
700 info=[
701 "Do not use $dumpfile(...) in your Apio testbenches.",
702 "Let Apio configure automatically the proper "
703 + "locations of the dump files.",
704 ],
705 )
707 # -- Run the action but don't announce the action. We will print
708 # -- ourselves in report_source_files_issues.
709 return Action(
710 report_source_files_issues,
711 strfunction=None,
712 )
715def _print_pnr_report(
716 build_report: BuildReport,
717 verbose: bool,
718) -> None:
719 """Emit a user friendly report from build report."""
721 # -- Determine table title.
722 title = "All FPGA resources" if verbose else "Used FPGA Resource"
724 # -- Utilization table
725 table = Table(
726 show_header=True,
727 show_lines=False,
728 box=box.SQUARE,
729 border_style=BORDER,
730 title=title,
731 title_justify="left",
732 padding=(0, 2),
733 )
735 # -- Add columns.
736 table.add_column("RESOURCE", no_wrap=True)
737 table.add_column("USED", no_wrap=True, justify="right")
738 table.add_column("TOTAL", no_wrap=True, justify="right")
739 table.add_column("USED%", no_wrap=True, justify="right")
741 # -- Add rows
742 for res in build_report.resources:
743 # -- By default we skip unused resources
744 if not res.used and not verbose:
745 continue
747 used_str = f"{res.used} " if res.used else ""
748 available_str = f"{res.available} "
749 percents = int(100 * res.used / res.available)
750 percents_str = f"{percents}% " if res.used else ""
751 style = EMPH3 if res.used > 0 else None
752 table.add_row(
753 res.name, used_str, available_str, percents_str, style=style
754 )
756 # -- Render the utilization table table
757 cout()
758 ctable(table)
760 # -- Clocks table, if there is at least one clock.
761 if len(build_report.clocks) > 0:
763 table = Table(
764 show_header=True,
765 show_lines=True,
766 box=box.SQUARE,
767 border_style=BORDER,
768 title="Clock Information",
769 title_justify="left",
770 padding=(0, 2),
771 )
773 # -- Add columns
774 table.add_column("CLOCK", no_wrap=True)
775 table.add_column(
776 "MAX SPEED [Mhz]", no_wrap=True, justify="right", style=EMPH3
777 )
779 # -- Add rows.
780 for clk in build_report.clocks:
781 table.add_row(clk.name, f"{clk.fmax_mhz:.2f}")
783 # -- Render the clocks table
784 cout()
785 ctable(table)
787 # -- Print hints.
788 cout("")
789 if len(build_report.clocks) == 0:
790 cout("No clocks were found in the design.", style=INFO)
792 if not verbose: 792 ↛ exitline 792 didn't return from function '_print_pnr_report' because the condition on line 792 was always true
793 cout("Use '--verbose' for additional details.", style=INFO)
796def report_action(verbose: bool) -> FunctionAction:
797 """Returns a SCons action to format and print the PNR reort from the
798 PNR json report file. Used by the 'apio report' command.
799 'script_id' identifies the calling SConstruct script and 'verbose'
800 indicates if the --verbose flag was invoked."""
802 def print_pnr_report(
803 target: list[Alias],
804 source: list[File],
805 env: SConsEnvironment,
806 ):
807 """Action function. Loads the pnr json report and print in a user
808 friendly way."""
809 _ = (target, env) # Unused
810 pnr_json_file: File = source[0]
811 pnr_json_path: Path = Path(pnr_json_file.get_path())
812 build_report: BuildReport = read_build_report(pnr_json_path)
813 _print_pnr_report(build_report, verbose)
815 return Action(
816 print_pnr_report,
817 "Formatting pnr report.",
818 )
821def get_programmer_cmd(apio_env: ApioEnv) -> str:
822 """Return the programmer command as derived from the scons "prog"
823 arg."""
825 # Should be called only if scons paramsm has 'upload' target parmas.
826 params = apio_env.params
827 assert params.target.HasField("upload"), params
829 # Get the programer command template arg.
830 programmer_cmd = params.target.upload.programmer_cmd
831 assert programmer_cmd, params
833 # -- [NOTE] Generally speaking we would expect the command to include
834 # -- $SOURCE for the binary file path but since we allow custom commands
835 # -- using apio.ini's 'programmer-cmd' option, we don't check for it here.
837 return programmer_cmd
840def get_define_flags(apio_env: ApioEnv) -> str:
841 """Return a string with the -D flags for the verilog defines. Returns
842 an empty string if there are no defines."""
843 flags: list[str] = []
844 for define in apio_env.params.apio_env_params.defines:
845 flags.append("-D" + define)
847 return " ".join(flags)
850def iverilog_action(
851 apio_env: ApioEnv,
852 *,
853 verbose: bool,
854 is_interactive: bool,
855 extra_params: list[str] | None = None,
856 lib_dirs: list[Path] | None = None,
857 lib_files: list[Path] | None = None,
858) -> str:
859 """Construct an iverilog scons action string.
860 * env: Rhe scons environment.
861 * verbose: IVerilog will show extra info.
862 * is_interactive: True for apio sim, False otherwise.
863 * extra_params: Optional list of additional IVerilog params.
864 * lib_dirs: Optional list of dir paths to include.
865 * lib_files: Optional list of library files to compile.
866 *
867 * Returns the scons action string for the IVerilog command.
868 """
870 # pylint: disable=too-many-arguments
872 # -- Construct the action string.
873 # -- The -g2012 is for system-verilog support.
874 action = (
875 "iverilog -g2012 {0} -o $TARGET {1} {2} {3} {4} {5} $SOURCES"
876 ).format(
877 "-v" if verbose else "",
878 get_define_flags(apio_env),
879 f"-DAPIO_SIM={int(is_interactive)}",
880 map_str_params(extra_params, "{}"),
881 map_path_params(lib_dirs, '-I"{}"'),
882 map_path_params(lib_files, '"{}"'),
883 )
885 return action
888def basename(file_name: str) -> str:
889 """Given a file name, returns it with the extension removed."""
890 result, _ = os.path.splitext(file_name)
891 return result
894def make_verilator_config_builder(
895 lib_path: Path, rules_to_suppress: list[str]
896) -> Builder:
897 """Create a scons Builder that writes a verilator config file
898 (hardware.vlt) that suppresses warnings in the lib directory.
899 Rules_to_suppress is a list of Verilator rules that should be suppressed
900 for the given lib_path.
901 """
902 assert isinstance(lib_path, Path), lib_path
904 # -- Construct a glob of all library files.
905 glob_path = str(lib_path / "*")
907 # -- Escape for windows. A single backslash is converted into two.
908 glob_str = str(glob_path).replace("\\", "\\\\")
910 # -- Generate the files lines. We suppress a union of all the errors we
911 # -- encountered in all the architectures.
912 lines = ["`verilator_config"]
913 for rule in rules_to_suppress:
914 lines.append(f'lint_off -rule {rule} -file "{glob_str}"')
916 # -- Join the lines into text.
917 text = "\n".join(lines) + "\n"
919 def verilator_config_func(target, source, env):
920 """Creates a verilator .vlt config files."""
921 _ = (source, env) # Unused
922 with open(target[0].get_path(), "w", encoding="utf-8") as target_file:
923 target_file.write(text)
924 return 0
926 return Builder(
927 action=Action(
928 verilator_config_func, "Creating verilator config file."
929 ),
930 suffix=".vlt",
931 )