Coverage for apio / scons / plugin_util.py: 82%
301 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 02:47 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 02:47 +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, Callable
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, cwarning, 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 assert is_source_file(
203 file_node.name
204 ), f"Not a src file: {file_node.name}"
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(dependencies)
268 return apio_env.scons_env.Scanner(function=verilog_src_scanner_func)
271def verilator_lint_action(
272 apio_env: ApioEnv,
273 *,
274 extra_params: List[str] = None,
275 lib_dirs: List[Path] = None,
276 lib_files: List[Path] = None,
277) -> List[
278 Callable[
279 [
280 List[File],
281 List[Alias],
282 SConsEnvironment,
283 ],
284 None,
285 ]
286 | str,
287]:
288 """Construct an verilator scons action.
289 * extra_params: Optional additional arguments.
290 * libs_dirs: Optional directories for include search.
291 * lib_files: Optional additional files to include.
292 Returns an action in a form of a list with two steps, a function to call
293 and a string command.
294 """
296 # -- Sanity checks
297 assert apio_env.targeting_one_of("lint")
298 assert apio_env.params.target.HasField("lint")
300 # -- Keep short references.
301 params = apio_env.params
302 lint_params = params.target.lint
304 # -- Determine top module.
305 top_module = (
306 lint_params.top_module
307 if lint_params.top_module
308 else params.apio_env_params.top_module
309 )
311 # -- Construct the action
312 action = (
313 "verilator_bin --lint-only --quiet --bbox-unsup --timing "
314 "-Wno-TIMESCALEMOD -Wno-MULTITOP {0} -DAPIO_SIM=0 "
315 "{1} {2} {3} {4} {5} {6} {7} {8} {9} {10} $SOURCES"
316 ).format(
317 "" if lint_params.nosynth else "-DSYNTHESIZE",
318 "-Wall" if lint_params.verilator_all else "",
319 "-Wno-style" if lint_params.verilator_no_style else "",
320 map_params(lint_params.verilator_no_warns, "-Wno-{}"),
321 map_params(lint_params.verilator_warns, "-Wwarn-{}"),
322 f"--top-module {top_module}",
323 get_define_flags(apio_env),
324 map_params(extra_params, "{}"),
325 map_params(lib_dirs, '-I"{}"'),
326 "" if lint_params.novlt else apio_env.target + ".vlt",
327 map_params(lib_files, '"{}"'),
328 )
330 return [source_files_issue_scanner_action(), action]
333@dataclass(frozen=True)
334class TestbenchInfo:
335 """Testbench simulation parameters, used by apio sim and apio test
336 commands."""
338 testbench_path: str # The relative testbench file path.
339 build_testbench_name: str # testbench_name prefixed by build dir.
340 srcs: List[str] # List of source files to compile.
342 @property
343 def testbench_name(self) -> str:
344 """The testbench path without the file extension."""
345 return basename(self.testbench_path)
348def detached_action(api_env: ApioEnv, cmd: List[str]) -> Action:
349 """
350 Launch the given command, given as a list of tokens, in a detached
351 (non blocking) mode.
352 """
354 def action_func(
355 target: List[Alias], source: List[File], env: SConsEnvironment
356 ):
357 """A call back function to perform the detached command invocation."""
359 # -- Make the linter happy
360 # pylint: disable=consider-using-with
361 _ = (target, source, env)
363 # -- NOTE: To debug these Popen operations, comment out the stdout=
364 # -- and stderr= lines to see the output and error messages from the
365 # -- commands.
367 # -- Handle the case of Window.
368 if api_env.is_windows:
369 creationflags = (
370 subprocess.DETACHED_PROCESS
371 | subprocess.CREATE_NEW_PROCESS_GROUP
372 )
373 subprocess.Popen(
374 cmd,
375 creationflags=creationflags,
376 stdout=subprocess.DEVNULL,
377 stderr=subprocess.DEVNULL,
378 close_fds=True,
379 shell=False,
380 )
381 return 0
383 # -- Handle the rest (macOS and Linux)
384 subprocess.Popen(
385 cmd,
386 stdout=subprocess.DEVNULL,
387 stderr=subprocess.DEVNULL,
388 close_fds=True,
389 start_new_session=True,
390 shell=False,
391 )
392 return 0
394 # -- Create the command display string that will be shown to the user.
395 cmd_str: str = subprocess.list2cmdline(cmd)
396 display_str: str = "[detached] " + cmd_str
398 # -- Create the action and return.
399 action = Action(action_func, display_str)
400 return action
403def gtkwave_target(
404 apio_env: ApioEnv,
405 target_name: str, # always 'sim'
406 vcd_file_target: NodeList,
407 testbench_info: TestbenchInfo,
408 sim_params: SimParams,
409 gtkwave_extra_options: Optional[List[str]],
410) -> List[Alias]:
411 """Construct a target to launch the QTWave signal viewer.
412 vcd_file_target is the simulator target that generated the vcd file
413 with the signals. Returns the new targets.
414 """
416 # pylint: disable=too-many-arguments
417 # pylint: disable=too-many-positional-arguments
419 # -- Construct the list of actions.
420 actions = []
422 # -- If needed, generate default .gtkw file to make sure the top level
423 # -- signals are shown by default.
424 gtkw_path: str = testbench_info.testbench_name + ".gtkw"
425 vcd_path = str(vcd_file_target[0])
427 def create_default_gtkw_file(
428 target: List[Alias], source: List[File], env: SConsEnvironment
429 ):
430 """The action function to generate the default .gtkw file."""
431 _ = (target, source, env) # Unused.
432 cout(f"Generating default {gtkw_path}")
433 gtkwave_util.create_gtkwave_file(
434 testbench_info.testbench_path, vcd_path, gtkw_path
435 )
437 if gtkwave_util.is_user_gtkw_file(gtkw_path):
438 cout(f"Found user saved {gtkw_path}")
439 else:
440 actions.append(Action(create_default_gtkw_file, strfunction=None))
442 # -- Skip or execute gtkwave.
443 if sim_params.no_gtkwave: 443 ↛ 459line 443 didn't jump to line 459 because the condition on line 443 was always true
444 # -- User asked to skip gtkwave. The '@' suppresses the printing
445 # -- of the echo command itself.
446 actions.append(
447 "@echo 'Flag --no-gtkwave was found, skipping GTKWave.'"
448 )
450 else:
451 # -- Normal case, invoking gtkwave.
453 # -- On windows we need to setup the cache. This could be done once
454 # -- when the oss-cad-suite is installed but since we currently don't
455 # -- have a package setup mechanism, we do it here on each invocation.
456 # -- The time penalty is negligible.
457 # -- With the stock oss-cad-suite windows package, this is done in the
458 # -- environment.bat script.
459 if apio_env.is_windows:
460 actions.append("gdk-pixbuf-query-loaders --update-cache")
462 # -- The actual wave viewer command.
463 gtkwave_cmd = ["gtkwave"]
464 # -- NOTE: Users can override these rcvars by adding the desired
465 # -- rcvar options in apio.ini gtkwave-extra-options which will win
466 # -- since they will appear later in the command line.
467 gtkwave_cmd.append("--rcvar=splash_disable on")
468 gtkwave_cmd.append("--rcvar=do_initial_zoom_fit 1")
469 if gtkwave_extra_options:
470 gtkwave_cmd.extend(gtkwave_extra_options)
471 gtkwave_cmd.extend([vcd_path, gtkw_path])
473 # -- Handle the case where gtkwave is run as a detached app, not
474 # -- waiting for it to close and not showing its output.
475 if sim_params.detach_gtkwave:
476 gtkwave_action = detached_action(apio_env, gtkwave_cmd)
477 else:
478 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd)
480 actions.append(gtkwave_action)
482 # -- Define a target with the action(s) we created.
483 target = apio_env.alias(
484 target_name,
485 source=vcd_file_target,
486 action=actions,
487 always_build=True,
488 )
490 return target
493def check_valid_testbench_name(testbench: str) -> None:
494 """Check if a testbench name is valid. If not, print an error message
495 and exit."""
496 if not is_source_file(testbench) or not has_testbench_name(testbench): 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true
497 cerror(f"'{testbench}' is not a valid testbench file name.")
498 cout(TESTBENCH_HINT, style=INFO)
499 sys.exit(1)
502def get_apio_sim_testbench_info(
503 apio_env: ApioEnv,
504 sim_params: SimParams,
505 synth_srcs: List[str],
506 test_srcs: List[str],
507) -> TestbenchInfo:
508 """Returns a SimulationConfig for a sim command. 'testbench' is
509 an optional testbench file name. 'synth_srcs' and 'test_srcs' are the
510 all the project's synth and testbench files found in the project as
511 returned by get_project_source_files()."""
513 # -- Handle the testbench file selection. The end result is a single
514 # -- testbench file name in testbench that we simulate, or a fatal error.
515 if sim_params.testbench_path:
516 # -- Case 1 - Testbench file name is specified in the command or
517 # -- apio.ini. Fatal error if invalid.
518 check_valid_testbench_name(sim_params.testbench_path)
519 testbench = sim_params.testbench_path
520 elif len(test_srcs) == 0: 520 ↛ 523line 520 didn't jump to line 523 because the condition on line 520 was never true
521 # -- Case 2 Testbench name was not specified and no testbench files
522 # -- were found in the project.
523 cerror("No testbench files found in the project.")
524 cout(TESTBENCH_HINT, style=INFO)
525 sys.exit(1)
526 elif len(test_srcs) == 1: 526 ↛ 534line 526 didn't jump to line 534 because the condition on line 526 was always true
527 # -- Case 3 Testbench name was not specified but there is exactly
528 # -- one in the project.
529 testbench = test_srcs[0]
530 cout(f"Found testbench file {testbench}", style=EMPH1)
531 else:
532 # -- Case 4 Testbench name was not specified and there are multiple
533 # -- testbench files in the project.
534 cerror("Multiple testbench files found in the project.")
535 cout(
536 "Please specify the testbench file name in the command ",
537 "or specify the 'default-testbench' option in apio.ini.",
538 style=INFO,
539 )
540 sys.exit(1)
542 # -- This should not happen. If it does, it's a programming error.
543 assert testbench, "get_sim_config(): Missing testbench file name"
545 # -- Construct a SimulationParams with all the synth files + the
546 # -- testbench file.
547 testbench_name = basename(testbench)
548 build_testbench_name = str(apio_env.env_build_path / testbench_name)
549 srcs = synth_srcs + [testbench]
550 return TestbenchInfo(testbench, build_testbench_name, srcs)
553def get_apio_test_testbenches_infos(
554 apio_env: ApioEnv,
555 test_params: ApioTestParams,
556 synth_srcs: List[str],
557 test_srcs: list[str],
558) -> List[TestbenchInfo]:
559 """Return a list of SimulationConfigs for each of the testbenches that
560 need to be run for a 'apio test' command. If testbench is empty,
561 all the testbenches in test_srcs will be tested. Otherwise, only the
562 testbench in testbench will be tested. synth_srcs and test_srcs are
563 source and test file lists as returned by get_project_source_files()."""
564 # List of testbenches to be tested.
566 # -- Handle the testbench files selection. The end result is a list of one
567 # -- or more testbench file names in testbenches that we test.
568 if test_params.testbench_path:
569 # -- Case 1 - a testbench file name is specified in the command or
570 # -- apio.ini. Fatal error if invalid.
571 check_valid_testbench_name(test_params.testbench_path)
572 testbenches = [test_params.testbench_path]
573 elif len(test_srcs) == 0: 573 ↛ 576line 573 didn't jump to line 576 because the condition on line 573 was never true
574 # -- Case 2 - Testbench file name was not specified and there are no
575 # -- testbench files in the project.
576 cerror("No testbench files found in the project.")
577 cout(TESTBENCH_HINT, style=INFO)
578 sys.exit(1)
579 elif test_params.default_option:
580 # -- Case 3: using --default option with no default testbench
581 # -- specified in apio.ini. If we have exacly one testbench that
582 # -- this is the default testbench, otherwise this is an error.
583 if len(test_srcs) == 1: 583 ↛ 586line 583 didn't jump to line 586 because the condition on line 583 was always true
584 testbenches = [test_srcs[0]]
585 else:
586 cerror("Multiple testbench files found in the project.")
587 cout(
588 "To test only a single testbench, replace --default with the "
589 + "testbench",
590 "file path, or specify the 'default-testbench' "
591 + "option in apio.ini.",
592 style=INFO,
593 )
594 sys.exit(1)
595 else:
596 # -- Case 4 - Testbench file name was not specified but there are one
597 # -- or more testbench files in the project.
598 testbenches = test_srcs
600 # -- If this fails, it's a programming error.
601 assert testbenches, "get_tests_configs(): no testbenches"
603 # Construct a config for each testbench.
604 configs = []
605 for tb in testbenches:
606 testbench_name = basename(tb)
607 build_testbench_name = str(apio_env.env_build_path / testbench_name)
608 srcs = synth_srcs + [tb]
609 configs.append(TestbenchInfo(tb, build_testbench_name, srcs))
611 return configs
614def announce_testbench_action() -> FunctionAction:
615 """Returns an action that prints a title with the testbench name."""
617 def announce_testbench(
618 target: List[Alias],
619 source: List[File],
620 env: SConsEnvironment,
621 ):
622 """The action function."""
623 _ = (target, env) # Unused
625 # -- We expect to find exactly one testbench.
626 testbenches = [
627 file
628 for file in source
629 if (is_source_file(file.name) and has_testbench_name(file.name))
630 ]
631 assert len(testbenches) == 1, testbenches
633 # -- Announce it.
634 cout()
635 cout(f"Testbench {testbenches[0]}", style=EMPH3)
637 # -- Run the action but don't announce the action.
638 return Action(announce_testbench, strfunction=None)
641def source_files_issue_scanner_action() -> FunctionAction:
642 """Returns a SCons action that scans the source files and print
643 error or warning messages about issues it finds."""
645 # A regex to identify "$dumpfile(" in testbenches.
646 testbench_dumpfile_re = re.compile(r"[$]dumpfile\s*[(]")
648 # A regex to identify "INTERACTIVE_SIM" in testbenches
649 interactive_sim_re = re.compile(r"INTERACTIVE_SIM")
651 def report_source_files_issues(
652 target: List[Alias],
653 source: List[File],
654 env: SConsEnvironment,
655 ):
656 """The scanner function."""
658 _ = (target, env) # Unused
660 for file in source:
662 # -- For now we report issues only in testbenches so skip
663 # -- otherwise.
664 if not is_source_file(file.name) or not has_testbench_name(
665 file.name
666 ):
667 continue
669 # -- Read the testbench file text.
670 file_text = file.get_text_contents()
672 # -- if contains $dumpfile, it's a fatal error. Apio sets the
673 # -- default location of the testbenches output .vcd file.
674 if testbench_dumpfile_re.findall(file_text): 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true
675 cerror(
676 f"The testbench file '{file.name}' contains '$dumpfile'."
677 )
678 cout(
679 "Do not use $dumpfile(...) in your Apio testbenches.",
680 "Let Apio configure automatically the proper locations of "
681 + "the dump files.",
682 style=INFO,
683 )
684 sys.exit(1)
686 # -- if contains $dumpfile, print a warning.
687 if interactive_sim_re.findall(file_text): 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true
688 cwarning(
689 "The Apio macro `INTERACTIVE_SIM is deprecated. "
690 "Use `APIO_SIM (0 or 1) instead."
691 )
693 # -- Run the action but don't announce the action. We will print
694 # -- ourselves in report_source_files_issues.
695 return Action(report_source_files_issues, strfunction=None)
698def _print_pnr_utilization_report(report: Dict[str, any]):
699 table = Table(
700 show_header=True,
701 show_lines=False,
702 box=box.SQUARE,
703 border_style=BORDER,
704 title="FPGA Resource Utilization",
705 title_justify="left",
706 padding=(0, 2),
707 )
709 # -- Add columns.
710 table.add_column("RESOURCE", no_wrap=True)
711 table.add_column("USED", no_wrap=True, justify="right")
712 table.add_column("TOTAL", no_wrap=True, justify="right")
713 table.add_column("UTIL.", no_wrap=True, justify="right")
715 # -- Add rows
716 utilization = report["utilization"]
717 for resource, vals in utilization.items():
718 used = vals["used"]
719 used_str = f"{used} " if used else ""
720 available = vals["available"]
721 available_str = f"{available} "
722 percents = int(100 * used / available)
723 percents_str = f"{percents}% " if used else ""
724 style = EMPH3 if used > 0 else None
725 table.add_row(
726 resource, used_str, available_str, percents_str, style=style
727 )
729 # -- Render the table
730 cout()
731 ctable(table)
734def _maybe_print_pnr_clocks_report(
735 report: Dict[str, any], clk_name_index: int
736) -> bool:
737 clocks = report["fmax"]
738 if len(clocks) == 0:
739 return False
741 table = Table(
742 show_header=True,
743 show_lines=True,
744 box=box.SQUARE,
745 border_style=BORDER,
746 title="Clock Information",
747 title_justify="left",
748 padding=(0, 2),
749 )
751 # -- Add columns
752 table.add_column("CLOCK", no_wrap=True)
753 table.add_column(
754 "MAX SPEED [Mhz]", no_wrap=True, justify="right", style=EMPH3
755 )
757 # -- Add rows.
758 clocks = report["fmax"]
759 for clk_net, vals in clocks.items():
760 # -- Extract clock name from the net name.
761 clk_signal = clk_net.split("$")[clk_name_index]
762 # -- Remove trailing '_'. Otherwise, on alhambra-ii/pll example, the
763 # -- internal clock 'sys_clk' is reported as 'sys_clk_'.
764 clk_signal = clk_signal.rstrip("_")
765 # -- Extract speed
766 max_mhz = vals["achieved"]
767 # -- Add row.
768 table.add_row(clk_signal, f"{max_mhz:.2f}")
770 # -- Render the table
771 cout()
772 ctable(table)
773 return True
776def _print_pnr_report(
777 json_txt: str,
778 clk_name_index: int,
779 verbose: bool,
780) -> None:
781 """Accepts the text of the pnr json report and prints it in
782 a user friendly way. Used by the 'apio report' command."""
783 # -- Parse the json text into a tree of dicts.
784 report: Dict[str, any] = json.loads(json_txt)
786 # -- Print the utilization table.
787 _print_pnr_utilization_report(report)
789 # -- Print the optional clocks table.
790 clock_report_printed = _maybe_print_pnr_clocks_report(
791 report, clk_name_index
792 )
794 # -- Print summary.
795 cout("")
796 if not clock_report_printed:
797 cout("No clocks were found in the design.", style=INFO)
798 if not verbose: 798 ↛ exitline 798 didn't return from function '_print_pnr_report' because the condition on line 798 was always true
799 cout("Run 'apio report --verbose' for more details.", style=INFO)
802def report_action(clk_name_index: int, verbose: bool) -> FunctionAction:
803 """Returns a SCons action to format and print the PNR reort from the
804 PNR json report file. Used by the 'apio report' command.
805 'script_id' identifies the calling SConstruct script and 'verbose'
806 indicates if the --verbose flag was invoked."""
808 def print_pnr_report(
809 target: List[Alias],
810 source: List[File],
811 env: SConsEnvironment,
812 ):
813 """Action function. Loads the pnr json report and print in a user
814 friendly way."""
815 _ = (target, env) # Unused
816 json_file: File = source[0]
817 print(f"{str(json_file)=}")
818 json_txt: str = json_file.get_text_contents()
819 _print_pnr_report(json_txt, clk_name_index, verbose)
821 return Action(print_pnr_report, "Formatting pnr report.")
824def get_programmer_cmd(apio_env: ApioEnv) -> str:
825 """Return the programmer command as derived from the scons "prog"
826 arg."""
828 # Should be called only if scons paramsm has 'upload' target parmas.
829 params = apio_env.params
830 assert params.target.HasField("upload"), params
832 # Get the programer command template arg.
833 programmer_cmd = params.target.upload.programmer_cmd
834 assert programmer_cmd, params
836 # -- [NOTE] Generally speaking we would expect the command to include
837 # -- $SOURCE for the binary file path but since we allow custom commands
838 # -- using apio.ini's 'programmer-cmd' option, we don't check for it here.
840 return programmer_cmd
843def get_define_flags(apio_env: ApioEnv) -> str:
844 """Return a string with the -D flags for the verilog defines. Returns
845 an empty string if there are no defines."""
846 flags: List[str] = []
847 for define in apio_env.params.apio_env_params.defines:
848 flags.append("-D" + define)
850 return " ".join(flags)
853def iverilog_action(
854 apio_env: ApioEnv,
855 *,
856 verbose: bool,
857 vcd_output_name: str,
858 is_interactive: bool,
859 extra_params: List[str] = None,
860 lib_dirs: List[Path] = None,
861 lib_files: List[Path] = None,
862) -> str:
863 """Construct an iverilog scons action string.
864 * env: Rhe scons environment.
865 * verbose: IVerilog will show extra info.
866 * vcd_output_name: Value for the macro VCD_OUTPUT.
867 * is_interactive: True for apio sim, False otherwise.
868 * extra_params: Optional list of additional IVerilog params.
869 * lib_dirs: Optional list of dir paths to include.
870 * lib_files: Optional list of library files to compile.
871 *
872 * Returns the scons action string for the IVerilog command.
873 """
875 # pylint: disable=too-many-arguments
877 # Escaping for windows. '\' -> '\\'
878 escaped_vcd_output_name = vcd_output_name.replace("\\", "\\\\")
880 # -- Construct the action string.
881 # -- The -g2012 is for system-verilog support.
882 action = (
883 "iverilog -g2012 {0} -o $TARGET {1} {2} {3} {4} {5} {6} {7} $SOURCES"
884 ).format(
885 "-v" if verbose else "",
886 f"-DVCD_OUTPUT={escaped_vcd_output_name}",
887 get_define_flags(apio_env),
888 f"-DAPIO_SIM={int(is_interactive)}",
889 # 'INTERACTIVE_SIM is deprecated and will go away.
890 "-DINTERACTIVE_SIM" if is_interactive else "",
891 map_params(extra_params, "{}"),
892 map_params(lib_dirs, '-I"{}"'),
893 map_params(lib_files, '"{}"'),
894 )
896 return action
899def basename(file_name: str) -> str:
900 """Given a file name, returns it with the extension removed."""
901 result, _ = os.path.splitext(file_name)
902 return result
905def make_verilator_config_builder(
906 lib_path: Path, rules_to_supress: List[str]
907) -> Builder:
908 """Create a scons Builder that writes a verilator config file
909 (hardware.vlt) that suppresses warnings in the lib directory.
910 Rules_to_supress is a list of Verilator rules that should be supressed
911 for the given lib_path.
912 """
913 assert isinstance(lib_path, Path), lib_path
915 # -- Construct a glob of all library files.
916 glob_path = str(lib_path / "*")
918 # -- Escape for windows. A single backslash is converted into two.
919 glob_str = str(glob_path).replace("\\", "\\\\")
921 # -- Generate the files lines. We suppress a union of all the errors we
922 # -- encountered in all the architectures.
923 lines = ["`verilator_config"]
924 for rule in rules_to_supress:
925 lines.append(f'lint_off -rule {rule} -file "{glob_str}"')
927 # -- Join the lines into text.
928 text = "\n".join(lines) + "\n"
930 def verilator_config_func(target, source, env):
931 """Creates a verilator .vlt config files."""
932 _ = (source, env) # Unused
933 with open(target[0].get_path(), "w", encoding="utf-8") as target_file:
934 target_file.write(text)
935 return 0
937 return Builder(
938 action=Action(
939 verilator_config_func, "Creating verilator config file."
940 ),
941 suffix=".vlt",
942 )