Coverage for apio/scons/plugin_util.py: 81%
309 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 03:51 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 03:51 +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."""
13from glob import glob
14import sys
15import os
16import re
17import json
18import subprocess
19from dataclasses import dataclass
20from pathlib import Path
21from typing import List, Dict, Optional, Union
22from rich.table import Table
23from rich import box
24from SCons import Scanner
25from SCons.Builder import Builder
26from SCons.Action import FunctionAction, Action
27from SCons.Node.FS import File
28from SCons.Script.SConscript import SConsEnvironment
29from SCons.Node import NodeList
30from SCons.Node.Alias import Alias
31from apio.scons.apio_env import ApioEnv
32from apio.common.proto.apio_pb2 import SimParams, ApioTestParams
33from apio.common.common_util import (
34 PROJECT_BUILD_PATH,
35 has_testbench_name,
36 is_source_file,
37)
38from apio.common.apio_console import cout, cerror, ctable
39from apio.common.apio_styles import INFO, BORDER, EMPH1, EMPH2, EMPH3
40from apio.scons import gtkwave_util
43TESTBENCH_HINT = "Testbench file names must end with '_tb.v' or '_tb.sv'."
46def map_params(params: Optional[List[Union[str, Path]]], fmt: str) -> str:
47 """A common function construct a command string snippet from a list
48 of arguments. The functon does the following:
49 1. If params arg is None replace it with []
50 2. Drops empty or white space only items.
51 3. Maps the items using the format string which contains exactly one
52 placeholder {}.
53 4. Joins the items with a white space char.
55 For examples, see the unit test at test_scons_util.py.
56 """
57 # None designates empty list. Avoiding the pylint non safe default
58 # warning.
59 if params is None:
60 params = []
62 # Convert params to stripped strings.
63 params = [str(x).strip() for x in params]
65 # Drop the empty params and map the rest.
66 mapped_params = [fmt.format(x) for x in params if x]
68 # Join using a single space.
69 return " ".join(mapped_params)
72def get_constraint_file(apio_env: ApioEnv, file_ext: str) -> str:
73 """Returns the name of the constraint file to use.
75 env is the sconstruction environment.
77 file_ext is a string with the constrained file extension.
78 E.g. ".pcf" for ice40.
80 Returns the file name if found or exit with an error otherwise.
81 """
83 # -- If the user specified a 'constraint-file' in apio.ini then use it.
84 user_specified = apio_env.params.apio_env_params.constraint_file
86 if user_specified:
87 path = Path(user_specified)
88 # -- Path should be relative.
89 if path.is_absolute(): 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 cerror(f"Constraint file path is not relative: {user_specified}")
91 sys.exit(1)
92 # -- Constrain file extension should match the architecture.
93 if path.suffix != file_ext:
94 cerror(
95 f"Constraint file should have the extension '{file_ext}': "
96 f"{user_specified}."
97 )
98 sys.exit(1)
99 # -- File should not be under _build
100 if PROJECT_BUILD_PATH in path.parents:
101 cerror(
102 f"Constraint file should not be under {PROJECT_BUILD_PATH}: "
103 f"{user_specified}."
104 )
105 sys.exit(1)
106 # -- Path should not contain '..' to avoid traveling outside of the
107 # -- project and coming back.
108 for part in path.parts:
109 if part == "..":
110 cerror(
111 f"Constraint file path should not contain '..': "
112 f"{user_specified}."
113 )
114 sys.exit(1)
116 # -- Constrain file looks good.
117 return user_specified
119 # -- No user specified constraint file, we will try to look for it
120 # -- in the project tree.
121 glob_files: List[str] = glob(f"**/*{file_ext}", recursive=True)
123 # -- Exclude files that are under _build
124 filtered_files: List[str] = [
125 f for f in glob_files if PROJECT_BUILD_PATH not in Path(f).parents
126 ]
128 # -- Handle by file count.
129 n = len(filtered_files)
131 # -- Case 1: No matching constrain files.
132 if n == 0:
133 cerror(f"No constraint file '*{file_ext}' found.")
134 sys.exit(1)
136 # -- Case 2: Exactly one constrain file found.
137 if n == 1:
138 result = str(filtered_files[0])
139 return result
141 # -- Case 3: Multiple matching constrain files.
142 cerror(
143 f"Found {n} constraint files '*{file_ext}' "
144 "in the project tree, which one to use?"
145 )
146 cout(
147 "Use the apio.ini constraint-file option to specify the desired file.",
148 style=INFO,
149 )
150 sys.exit(1)
153def verilog_src_scanner(apio_env: ApioEnv) -> Scanner.Base:
154 """Creates and returns a scons Scanner object for scanning verilog
155 files for dependencies.
156 """
157 # A Regex to icestudio propriaetry references for *.list files.
158 # Example:
159 # Text: ' parameter v771499 = "v771499.list"'
160 # Captures: 'v771499.list'
161 icestudio_list_re = re.compile(r"[\n|\s][^\/]?\"(.*\.list?)\"", re.M)
163 # A regex to match a verilog include directive.
164 # Example
165 # Text: `include "apio_testing.vh"
166 # Capture: 'apio_testing.vh'
167 verilog_include_re = re.compile(r'`\s*include\s+["]([^"]+)["]', re.M)
169 # A regex for inclusion via $readmemh()
170 # Example
171 # Test: '$readmemh("my_data.hex", State_buff);'
172 # Capture: 'my_data.hex'
173 readmemh_reference_re = re.compile(
174 r"\$readmemh\([\'\"]([^\'\"]+)[\'\"]", re.M
175 )
177 # -- List of required and optional files that may require a rebuild if
178 # -- changed.
179 core_dependencies = [
180 "apio.ini",
181 "boards.jsonc",
182 "fpgas.jsonc",
183 "programmers.jsonc",
184 ]
186 def verilog_src_scanner_func(
187 file_node: File, env: SConsEnvironment, ignored_path
188 ) -> List[str]:
189 """Given a [System]Verilog file, scan it and return a list of
190 references to other files it depends on. It's not require to report
191 dependency on another source file in the project since scons loads
192 anyway all the source files in the project.
194 Returns a list of files. Dependencies that don't have an existing
195 file are ignored and not returned. This is to avoid references in
196 commented out code to break scons dependencies.
197 """
198 _ = env # Unused
200 # Sanity check. Should be called only to scan verilog files. If
201 # this fails, this is a programming error rather than a user error.
202 if not is_source_file(file_node.name): 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 cerror(f"'{file_node.name}' is not a source file.")
204 sys.exit(1)
206 # Get the directory of the file, relative to the project root which is
207 # the current working directory. This value is equals to "." if the
208 # file is in the project root.
209 file_dir: str = file_node.get_dir().get_path()
211 # Prepare an empty set of dependencies.
212 candidates_raw_set = set()
214 # Read the file. This returns [] if the file doesn't exist.
215 file_content = file_node.get_text_contents()
217 # Get verilog includes references.
218 candidates_raw_set.update(verilog_include_re.findall(file_content))
220 # Get $readmemh() function references.
221 candidates_raw_set.update(readmemh_reference_re.findall(file_content))
223 # Get IceStudio references.
224 candidates_raw_set.update(icestudio_list_re.findall(file_content))
226 # Since we don't know if the dependency's path is relative to the file
227 # location or the project root, we try both. We prefer to have high
228 # recall of dependencies of high precision, risking at most unnecessary
229 # rebuilds.
230 candidates_set = candidates_raw_set.copy()
231 # If the file is not in the project dir, add a dependency also relative
232 # to the project dir.
233 if file_dir != ".":
234 for raw_candidate in candidates_raw_set:
235 candidate: str = os.path.join(file_dir, raw_candidate)
236 candidates_set.add(candidate)
238 # Add the core dependencies. They are always relative to the project
239 # root.
240 candidates_set.update(core_dependencies)
242 # Filter out candidates that don't have a matching files to prevert
243 # breaking the build. This handle for example the case where the
244 # file references is in a comment or non reachable code.
245 # See also https://stackoverflow.com/q/79302552/15038713
246 dependencies = []
247 for dependency in candidates_set:
248 if Path(dependency).exists():
249 dependencies.append(dependency)
250 elif apio_env.is_debug(1): 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 cout(
252 f"Dependency candidate {dependency} does not exist, "
253 "dropping."
254 )
256 # Sort the strings for determinism.
257 dependencies = sorted(list(dependencies))
259 # Debug info.
260 if apio_env.is_debug(1): 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 cout(f"Dependencies of {file_node}:", style=EMPH2)
262 for dependency in dependencies:
263 cout(f" {dependency}", style=EMPH2)
265 # All done
266 return apio_env.scons_env.File(
267 dependencies
268 ) # pyright: ignore[reportReturnType]
270 return apio_env.scons_env.Scanner(function=verilog_src_scanner_func)
273def verilator_lint_action(
274 apio_env: ApioEnv,
275 *,
276 extra_params: List[str] | None = None,
277 lib_dirs: List[Path] | None = None,
278 lib_files: List[Path] | None = None,
279) -> List[FunctionAction | str]:
280 # -> List[
281 # Callable[
282 # [
283 # List[File],
284 # List[Alias],
285 # SConsEnvironment,
286 # ],
287 # None,
288 # ]
289 # | str,
290 # ]:
291 """Construct an verilator scons action.
292 * extra_params: Optional additional arguments.
293 * libs_dirs: Optional directories for include search.
294 * lib_files: Optional additional files to include.
295 Returns an action in a form of a list with two steps, a function to call
296 and a string command.
297 """
299 # -- Sanity checks
300 assert apio_env.targeting_one_of("lint")
301 assert apio_env.params.target.HasField("lint")
303 # -- Keep short references.
304 params = apio_env.params
305 lint_params = params.target.lint
307 # -- Determine if linting the entire project or just a few files,
308 lint_whole_project = not lint_params.file_names
310 # -- Determine if using a vlt file. We use it only when linting a whole
311 # -- project and --novlt was not specified.
312 using_vlt = lint_whole_project and (not lint_params.novlt)
314 # -- Determine the top module.
315 if lint_params.top_module:
316 # -- Case 1: Top module was specified in the command line.
317 top_module = lint_params.top_module
318 elif lint_whole_project:
319 # -- Case 2: Linting the entire project, use top module from apio.ini,
320 top_module = params.apio_env_params.top_module
321 else:
322 # -- Linting only a few files and top module was not specified.
323 top_module = None
325 print(f"{params.apio_env_params.verilator_extra_options=}")
326 # -- Construct the action
327 action = (
328 "verilator_bin --lint-only --quiet --bbox-unsup --timing "
329 "-Wno-TIMESCALEMOD -Wno-MULTITOP {0} {1} -DAPIO_SIM=0 "
330 "{2} {3} {4} {5} {6} {7} {8} $SOURCES"
331 ).format(
332 "" if lint_params.nosynth else "-DSYNTHESIZE",
333 "" if lint_whole_project else "-Wno-MODMISSING",
334 " ".join(params.apio_env_params.verilator_extra_options),
335 f"--top-module {top_module}" if top_module else "",
336 get_define_flags(apio_env),
337 map_params(extra_params, "{}"), # pyright: ignore[reportArgumentType]
338 (
339 map_params(
340 lib_dirs, '-I"{}"' # pyright: ignore[reportArgumentType]
341 )
342 if lint_whole_project
343 else ""
344 ),
345 apio_env.target + ".vlt" if using_vlt else "",
346 (
347 map_params(
348 lib_files, '"{}"' # pyright: ignore[reportArgumentType]
349 )
350 if lint_whole_project
351 else ""
352 ),
353 )
355 # pyright: ignore[reportReturnType]
356 return [
357 source_files_issue_scanner_action(),
358 str(action),
359 ]
362@dataclass(frozen=True)
363class TestbenchInfo:
364 """Testbench simulation parameters, used by apio sim and apio test
365 commands."""
367 testbench_path: str # The relative testbench file path.
368 build_testbench_name: str # testbench_name prefixed by build dir.
369 srcs: List[str] # List of source files to compile.
371 @property
372 def testbench_name(self) -> str:
373 """The testbench path without the file extension."""
374 return basename(self.testbench_path)
377def detached_action(
378 api_env: ApioEnv, cmd: List[str]
379) -> Action: # pyright: ignore[reportGeneralTypeIssues]
380 """
381 Launch the given command, given as a list of tokens, in a detached
382 (non blocking) mode.
383 """
385 def action_func(
386 target: List[Alias], source: List[File], env: SConsEnvironment
387 ):
388 """A call back function to perform the detached command invocation."""
390 # -- Make the linter happy
391 # pylint: disable=consider-using-with
392 _ = (target, source, env)
394 # -- NOTE: To debug these Popen operations, comment out the stdout=
395 # -- and stderr= lines to see the output and error messages from the
396 # -- commands.
398 # -- Handle the case of Window.
399 if api_env.is_windows:
400 detached_flag = getattr(subprocess, "DETACHED_PROCESS", 0)
401 group_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
402 creationflags = detached_flag | group_flag
404 subprocess.Popen(
405 cmd,
406 creationflags=creationflags,
407 stdout=subprocess.DEVNULL,
408 stderr=subprocess.DEVNULL,
409 close_fds=True,
410 shell=False,
411 )
412 return 0
414 # -- Handle the rest (macOS and Linux)
415 subprocess.Popen(
416 cmd,
417 stdout=subprocess.DEVNULL,
418 stderr=subprocess.DEVNULL,
419 close_fds=True,
420 start_new_session=True,
421 shell=False,
422 )
423 return 0
425 # -- Create the command display string that will be shown to the user.
426 cmd_str: str = subprocess.list2cmdline(cmd)
427 display_str: str = "[detached] " + cmd_str
429 # -- Create the action and return
430 action = Action(action_func, display_str)
431 return action
434def gtkwave_target(
435 apio_env: ApioEnv,
436 target_name: str, # always 'sim'
437 vcd_file_target: NodeList,
438 testbench_info: TestbenchInfo,
439 sim_params: SimParams,
440 gtkwave_extra_options: Optional[List[str]],
441) -> List[Alias]:
442 """Construct a target to launch the QTWave signal viewer.
443 vcd_file_target is the simulator target that generated the vcd file
444 with the signals. Returns the new targets.
445 """
447 # pylint: disable=too-many-arguments
448 # pylint: disable=too-many-positional-arguments
450 # -- Construct the list of actions.
451 actions = []
453 # -- If needed, generate default .gtkw file to make sure the top level
454 # -- signals are shown by default.
455 gtkw_path: str = testbench_info.testbench_name + ".gtkw"
456 vcd_path = str(vcd_file_target[0])
458 def create_default_gtkw_file(
459 target: List[Alias], source: List[File], env: SConsEnvironment
460 ):
461 """The action function to generate the default .gtkw file."""
462 _ = (target, source, env) # Unused.
463 cout(f"Generating default {gtkw_path}")
464 gtkwave_util.create_gtkwave_file(
465 testbench_info.testbench_path, vcd_path, gtkw_path
466 )
468 if gtkwave_util.is_user_gtkw_file(gtkw_path):
469 cout(f"Found user saved {gtkw_path}")
470 else:
471 actions.append(Action(create_default_gtkw_file, strfunction=None))
473 # -- Skip or execute gtkwave.
474 if sim_params.no_gtkwave: 474 ↛ 490line 474 didn't jump to line 490 because the condition on line 474 was always true
475 # -- User asked to skip gtkwave. The '@' suppresses the printing
476 # -- of the echo command itself.
477 actions.append(
478 "@echo 'Flag --no-gtkwave was found, skipping GTKWave.'"
479 )
481 else:
482 # -- Normal case, invoking gtkwave.
484 # -- On windows we need to setup the cache. This could be done once
485 # -- when the oss-cad-suite is installed but since we currently don't
486 # -- have a package setup mechanism, we do it here on each invocation.
487 # -- The time penalty is negligible.
488 # -- With the stock oss-cad-suite windows package, this is done in the
489 # -- environment.bat script.
490 if apio_env.is_windows:
491 actions.append("gdk-pixbuf-query-loaders --update-cache")
493 # -- The actual wave viewer command.
494 gtkwave_cmd = ["gtkwave"]
495 # -- NOTE: Users can override these rcvars by adding the desired
496 # -- rcvar options in apio.ini gtkwave-extra-options which will win
497 # -- since they will appear later in the command line.
498 gtkwave_cmd.append("--rcvar=splash_disable on")
499 gtkwave_cmd.append("--rcvar=do_initial_zoom_fit 1")
500 if gtkwave_extra_options:
501 gtkwave_cmd.extend(gtkwave_extra_options)
502 gtkwave_cmd.extend([vcd_path, gtkw_path])
504 # -- Handle the case where gtkwave is run as a detached app, not
505 # -- waiting for it to close and not showing its output.
506 if sim_params.detach_gtkwave:
507 gtkwave_action = detached_action(apio_env, gtkwave_cmd)
508 else:
509 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd)
511 actions.append(gtkwave_action)
513 # -- Define a target with the action(s) we created.
514 target = apio_env.alias(
515 target_name,
516 source=vcd_file_target,
517 action=actions,
518 always_build=True,
519 )
521 return target
524def check_valid_testbench_name(testbench: str) -> None:
525 """Check if a testbench name is valid. If not, print an error message
526 and exit."""
527 if not is_source_file(testbench) or not has_testbench_name(testbench): 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true
528 cerror(f"'{testbench}' is not a valid testbench file name.")
529 cout(TESTBENCH_HINT, style=INFO)
530 sys.exit(1)
533def get_apio_sim_testbench_info(
534 apio_env: ApioEnv,
535 sim_params: SimParams,
536 synth_srcs: List[str],
537 test_srcs: List[str],
538) -> TestbenchInfo:
539 """Returns a SimulationConfig for a sim command. 'testbench' is
540 an optional testbench file name. 'synth_srcs' and 'test_srcs' are the
541 all the project's synth and testbench files found in the project as
542 returned by get_project_source_files()."""
544 # -- Handle the testbench file selection. The end result is a single
545 # -- testbench file name in testbench that we simulate, or a fatal error.
546 if sim_params.testbench_path:
547 # -- Case 1 - Testbench file name is specified in the command or
548 # -- apio.ini. Fatal error if invalid.
549 check_valid_testbench_name(sim_params.testbench_path)
550 testbench = sim_params.testbench_path
551 elif len(test_srcs) == 0: 551 ↛ 554line 551 didn't jump to line 554 because the condition on line 551 was never true
552 # -- Case 2 Testbench name was not specified and no testbench files
553 # -- were found in the project.
554 cerror("No testbench files found in the project.")
555 cout(TESTBENCH_HINT, style=INFO)
556 sys.exit(1)
557 elif len(test_srcs) == 1: 557 ↛ 565line 557 didn't jump to line 565 because the condition on line 557 was always true
558 # -- Case 3 Testbench name was not specified but there is exactly
559 # -- one in the project.
560 testbench = test_srcs[0]
561 cout(f"Found testbench file {testbench}", style=EMPH1)
562 else:
563 # -- Case 4 Testbench name was not specified and there are multiple
564 # -- testbench files in the project.
565 cerror("Multiple testbench files found in the project.")
566 cout(
567 "Please specify the testbench file name in the command ",
568 "or specify the 'default-testbench' option in apio.ini.",
569 style=INFO,
570 )
571 sys.exit(1)
573 # -- This should not happen. If it does, it's a programming error.
574 assert testbench, "get_sim_config(): Missing testbench file name"
576 # -- Construct a SimulationParams with all the synth files + the
577 # -- testbench file.
578 testbench_name = basename(testbench)
579 build_testbench_name = str(apio_env.env_build_path / testbench_name)
580 srcs = synth_srcs + [testbench]
581 return TestbenchInfo(testbench, build_testbench_name, srcs)
584def get_apio_test_testbenches_infos(
585 apio_env: ApioEnv,
586 test_params: ApioTestParams,
587 synth_srcs: List[str],
588 test_srcs: list[str],
589) -> List[TestbenchInfo]:
590 """Return a list of SimulationConfigs for each of the testbenches that
591 need to be run for a 'apio test' command. If testbench is empty,
592 all the testbenches in test_srcs will be tested. Otherwise, only the
593 testbench in testbench will be tested. synth_srcs and test_srcs are
594 source and test file lists as returned by get_project_source_files()."""
595 # List of testbenches to be tested.
597 # -- Handle the testbench files selection. The end result is a list of one
598 # -- or more testbench file names in testbenches that we test.
599 if test_params.testbench_path:
600 # -- Case 1 - a testbench file name is specified in the command or
601 # -- apio.ini. Fatal error if invalid.
602 check_valid_testbench_name(test_params.testbench_path)
603 testbenches = [test_params.testbench_path]
604 elif len(test_srcs) == 0: 604 ↛ 607line 604 didn't jump to line 607 because the condition on line 604 was never true
605 # -- Case 2 - Testbench file name was not specified and there are no
606 # -- testbench files in the project.
607 cerror("No testbench files found in the project.")
608 cout(TESTBENCH_HINT, style=INFO)
609 sys.exit(1)
610 elif test_params.default_option:
611 # -- Case 3: using --default option with no default testbench
612 # -- specified in apio.ini. If we have exacly one testbench that
613 # -- this is the default testbench, otherwise this is an error.
614 if len(test_srcs) == 1: 614 ↛ 617line 614 didn't jump to line 617 because the condition on line 614 was always true
615 testbenches = [test_srcs[0]]
616 else:
617 cerror("Multiple testbench files found in the project.")
618 cout(
619 "To test only a single testbench, replace --default with the "
620 + "testbench",
621 "file path, or specify the 'default-testbench' "
622 + "option in apio.ini.",
623 style=INFO,
624 )
625 sys.exit(1)
626 else:
627 # -- Case 4 - Testbench file name was not specified but there are one
628 # -- or more testbench files in the project.
629 testbenches = test_srcs
631 # -- If this fails, it's a programming error.
632 assert testbenches, "get_tests_configs(): no testbenches"
634 # Construct a config for each testbench.
635 configs = []
636 for tb in testbenches:
637 testbench_name = basename(tb)
638 build_testbench_name = str(apio_env.env_build_path / testbench_name)
639 srcs = synth_srcs + [tb]
640 configs.append(TestbenchInfo(tb, build_testbench_name, srcs))
642 return configs
645def announce_testbench_action() -> FunctionAction:
646 """Returns an action that prints a title with the testbench name."""
648 def announce_testbench(
649 target: List[Alias],
650 source: List[File],
651 env: SConsEnvironment,
652 ):
653 """The action function."""
654 _ = (target, env) # Unused
656 # -- We expect to find exactly one testbench.
657 testbenches = [
658 file
659 for file in source
660 if (is_source_file(file.name) and has_testbench_name(file.name))
661 ]
662 assert len(testbenches) == 1, testbenches
664 # -- Announce it.
665 cout()
666 cout(f"Testbench {testbenches[0]}", style=EMPH3)
668 # -- Run the action but don't announce the action.
669 return Action(
670 announce_testbench, # pyright: ignore[reportReturnType]
671 strfunction=None,
672 )
675def source_files_issue_scanner_action() -> FunctionAction:
676 """Returns a SCons action that scans the source files and print
677 error or warning messages about issues it finds."""
679 # A regex to identify "$dumpfile(" in testbenches.
680 testbench_dumpfile_re = re.compile(r"[$]dumpfile\s*[(]")
682 def report_source_files_issues(
683 target: List[Alias],
684 source: List[File],
685 env: SConsEnvironment,
686 ):
687 """The scanner function."""
689 _ = (target, env) # Unused
691 for file in source:
693 # -- For now we report issues only in testbenches so skip
694 # -- otherwise.
695 if not is_source_file(file.name) or not has_testbench_name(
696 file.name
697 ):
698 continue
700 # -- Read the testbench file text.
701 file_text = file.get_text_contents()
703 # -- if contains $dumpfile, it's a fatal error. Apio sets the
704 # -- default location of the testbenches output .vcd file.
705 if testbench_dumpfile_re.findall(file_text): 705 ↛ 706line 705 didn't jump to line 706 because the condition on line 705 was never true
706 cerror(
707 f"The testbench file '{file.name}' contains '$dumpfile'."
708 )
709 cout(
710 "Do not use $dumpfile(...) in your Apio testbenches.",
711 "Let Apio configure automatically the proper locations of "
712 + "the dump files.",
713 style=INFO,
714 )
715 sys.exit(1)
717 # -- Run the action but don't announce the action. We will print
718 # -- ourselves in report_source_files_issues.
719 return Action(
720 report_source_files_issues, # pyright: ignore[reportReturnType]
721 strfunction=None,
722 )
725def _print_pnr_utilization_report(
726 report: Dict[str, any], # pyright: ignore[reportGeneralTypeIssues]
727):
728 table = Table(
729 show_header=True,
730 show_lines=False,
731 box=box.SQUARE,
732 border_style=BORDER,
733 title="FPGA Resource Utilization",
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("UTIL.", no_wrap=True, justify="right")
744 # -- Add rows
745 utilization = report["utilization"]
746 for resource, vals in utilization.items():
747 used = vals["used"]
748 used_str = f"{used} " if used else ""
749 available = vals["available"]
750 available_str = f"{available} "
751 percents = int(100 * used / available)
752 percents_str = f"{percents}% " if used else ""
753 style = EMPH3 if used > 0 else None
754 table.add_row(
755 resource, used_str, available_str, percents_str, style=style
756 )
758 # -- Render the table
759 cout()
760 ctable(table)
763def _maybe_print_pnr_clocks_report(
764 report: Dict[str, any], # pyright: ignore[reportGeneralTypeIssues]
765 clk_name_index: int,
766) -> bool:
767 clocks = report["fmax"]
768 if len(clocks) == 0:
769 return False
771 table = Table(
772 show_header=True,
773 show_lines=True,
774 box=box.SQUARE,
775 border_style=BORDER,
776 title="Clock Information",
777 title_justify="left",
778 padding=(0, 2),
779 )
781 # -- Add columns
782 table.add_column("CLOCK", no_wrap=True)
783 table.add_column(
784 "MAX SPEED [Mhz]", no_wrap=True, justify="right", style=EMPH3
785 )
787 # -- Add rows.
788 clocks = report["fmax"]
789 for clk_net, vals in clocks.items():
790 # -- Extract clock name from the net name.
791 clk_signal = clk_net.split("$")[clk_name_index]
792 # -- Remove trailing '_'. Otherwise, on alhambra-ii/pll example, the
793 # -- internal clock 'sys_clk' is reported as 'sys_clk_'.
794 clk_signal = clk_signal.rstrip("_")
795 # -- Extract speed
796 max_mhz = vals["achieved"]
797 # -- Add row.
798 table.add_row(clk_signal, f"{max_mhz:.2f}")
800 # -- Render the table
801 cout()
802 ctable(table)
803 return True
806def _print_pnr_report(
807 json_txt: str,
808 clk_name_index: int,
809 verbose: bool,
810) -> None:
811 """Accepts the text of the pnr json report and prints it in
812 a user friendly way. Used by the 'apio report' command."""
813 # -- Parse the json text into a tree of dicts.
814 report: Dict[str, any] = ( # pyright: ignore[reportGeneralTypeIssues]
815 json.loads(json_txt)
816 )
818 # -- Print the utilization table.
819 _print_pnr_utilization_report(report)
821 # -- Print the optional clocks table.
822 clock_report_printed = _maybe_print_pnr_clocks_report(
823 report, clk_name_index
824 )
826 # -- Print summary.
827 cout("")
828 if not clock_report_printed:
829 cout("No clocks were found in the design.", style=INFO)
830 if not verbose: 830 ↛ exitline 830 didn't return from function '_print_pnr_report' because the condition on line 830 was always true
831 cout("Run 'apio report --verbose' for more details.", style=INFO)
834def report_action(clk_name_index: int, verbose: bool) -> FunctionAction:
835 """Returns a SCons action to format and print the PNR reort from the
836 PNR json report file. Used by the 'apio report' command.
837 'script_id' identifies the calling SConstruct script and 'verbose'
838 indicates if the --verbose flag was invoked."""
840 def print_pnr_report(
841 target: List[Alias],
842 source: List[File],
843 env: SConsEnvironment,
844 ):
845 """Action function. Loads the pnr json report and print in a user
846 friendly way."""
847 _ = (target, env) # Unused
848 json_file: File = source[0]
849 print(f"{str(json_file)=}")
850 json_txt: str = json_file.get_text_contents()
851 _print_pnr_report(json_txt, clk_name_index, verbose)
853 return Action(
854 print_pnr_report, # pyright: ignore[reportReturnType]
855 "Formatting pnr report.",
856 )
859def get_programmer_cmd(apio_env: ApioEnv) -> str:
860 """Return the programmer command as derived from the scons "prog"
861 arg."""
863 # Should be called only if scons paramsm has 'upload' target parmas.
864 params = apio_env.params
865 assert params.target.HasField("upload"), params
867 # Get the programer command template arg.
868 programmer_cmd = params.target.upload.programmer_cmd
869 assert programmer_cmd, params
871 # -- [NOTE] Generally speaking we would expect the command to include
872 # -- $SOURCE for the binary file path but since we allow custom commands
873 # -- using apio.ini's 'programmer-cmd' option, we don't check for it here.
875 return programmer_cmd
878def get_define_flags(apio_env: ApioEnv) -> str:
879 """Return a string with the -D flags for the verilog defines. Returns
880 an empty string if there are no defines."""
881 flags: List[str] = []
882 for define in apio_env.params.apio_env_params.defines:
883 flags.append("-D" + define)
885 return " ".join(flags)
888def iverilog_action(
889 apio_env: ApioEnv,
890 *,
891 verbose: bool,
892 vcd_output_name: str,
893 is_interactive: bool,
894 extra_params: List[str] | None = None,
895 lib_dirs: List[Path] | None = None,
896 lib_files: List[Path] | None = None,
897) -> str:
898 """Construct an iverilog scons action string.
899 * env: Rhe scons environment.
900 * verbose: IVerilog will show extra info.
901 * vcd_output_name: Value for the macro VCD_OUTPUT.
902 * is_interactive: True for apio sim, False otherwise.
903 * extra_params: Optional list of additional IVerilog params.
904 * lib_dirs: Optional list of dir paths to include.
905 * lib_files: Optional list of library files to compile.
906 *
907 * Returns the scons action string for the IVerilog command.
908 """
910 # pylint: disable=too-many-arguments
912 # Escaping for windows. '\' -> '\\'
913 escaped_vcd_output_name = vcd_output_name.replace("\\", "\\\\")
915 # -- Construct the action string.
916 # -- The -g2012 is for system-verilog support.
917 action = (
918 "iverilog -g2012 {0} -o $TARGET {1} {2} {3} {4} {5} {6} $SOURCES"
919 ).format(
920 "-v" if verbose else "",
921 f"-DVCD_OUTPUT={escaped_vcd_output_name}",
922 get_define_flags(apio_env),
923 f"-DAPIO_SIM={int(is_interactive)}",
924 map_params(extra_params, "{}"), # pyright: ignore[reportArgumentType]
925 map_params(lib_dirs, '-I"{}"'), # pyright: ignore[reportArgumentType]
926 map_params(lib_files, '"{}"'), # pyright: ignore[reportArgumentType]
927 )
929 return action
932def basename(file_name: str) -> str:
933 """Given a file name, returns it with the extension removed."""
934 result, _ = os.path.splitext(file_name)
935 return result
938def make_verilator_config_builder(
939 lib_path: Path, rules_to_supress: List[str]
940) -> Builder: # pyright: ignore[reportGeneralTypeIssues]
941 """Create a scons Builder that writes a verilator config file
942 (hardware.vlt) that suppresses warnings in the lib directory.
943 Rules_to_supress is a list of Verilator rules that should be supressed
944 for the given lib_path.
945 """
946 assert isinstance(lib_path, Path), lib_path
948 # -- Construct a glob of all library files.
949 glob_path = str(lib_path / "*")
951 # -- Escape for windows. A single backslash is converted into two.
952 glob_str = str(glob_path).replace("\\", "\\\\")
954 # -- Generate the files lines. We suppress a union of all the errors we
955 # -- encountered in all the architectures.
956 lines = ["`verilator_config"]
957 for rule in rules_to_supress:
958 lines.append(f'lint_off -rule {rule} -file "{glob_str}"')
960 # -- Join the lines into text.
961 text = "\n".join(lines) + "\n"
963 def verilator_config_func(target, source, env):
964 """Creates a verilator .vlt config files."""
965 _ = (source, env) # Unused
966 with open(target[0].get_path(), "w", encoding="utf-8") as target_file:
967 target_file.write(text)
968 return 0
970 return Builder(
971 action=Action(
972 verilator_config_func, "Creating verilator config file."
973 ),
974 suffix=".vlt",
975 )