Coverage for apio / managers / scons_manager.py: 80%
151 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 02:31 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 02:31 +0000
1"""A manager class for to dispatch the Apio SCONS targets."""
3# -*- coding: utf-8 -*-
4# -- This file is part of the Apio project
5# -- (C) 2016-2019 FPGAwars
6# -- Author Jesús Arroyo
7# -- License GPLv2
9import traceback
10import os
11import sys
12import time
13import shutil
14from functools import wraps
15from datetime import datetime
16from google.protobuf import text_format
17from apio.common import apio_console
18from apio.common.apio_console import cout, cerror, cstyle, cunstyle
19from apio.common.apio_styles import SUCCESS, ERROR, EMPH3, INFO
20from apio.utils import util
21from apio.apio_context import ApioContext
22from apio.managers.scons_filter import SconsFilter
23from apio.common.proto.apio_pb2 import (
24 FORCE_PIPE,
25 FORCE_TERMINAL,
26 Verbosity,
27 Environment,
28 SconsParams,
29 TargetParams,
30 FpgaInfo,
31 ApioEnvParams,
32 Ice40FpgaParams,
33 Ecp5FpgaParams,
34 GowinFpgaParams,
35 ApioArch,
36 GraphParams,
37 LintParams,
38 SimParams,
39 ApioTestParams,
40 UploadParams,
41)
43# from apio.common import rich_lib_windows
46# W0703: Catching too general exception Exception (broad-except)
47# pylint: disable=W0703
48#
49# -- Based on
50# -- https://stackoverflow.com/questions/5929107/decorators-with-parameters
51def on_exception(*, exit_code: int):
52 """Decorator for functions that return int exit code. If the function
53 throws an exception, the error message is printed, and the caller see the
54 returned value exit_code instead of the exception.
55 """
57 def decorator(function):
58 @wraps(function)
59 def wrapper(*args, **kwargs):
60 try:
61 return function(*args, **kwargs)
62 except Exception as exc:
63 if util.is_debug(1):
64 traceback.print_tb(exc.__traceback__)
66 if str(exc):
67 cerror(str(exc))
69 if not util.is_debug(1):
70 cout(
71 "Setting env var APIO_DEBUG=1 may provide "
72 "additional diagnostic information.",
73 style=INFO,
74 )
75 return exit_code
77 return wrapper
79 return decorator
82class SConsManager:
83 """Class for managing the scons tools"""
85 def __init__(self, apio_ctx: ApioContext):
86 """Initialization."""
87 # -- Cache the apio context.
88 self.apio_ctx = apio_ctx
90 # -- Change to the project's folder.
91 os.chdir(apio_ctx.project_dir)
93 @on_exception(exit_code=1)
94 def graph(self, graph_params: GraphParams, verbosity: Verbosity) -> int:
95 """Runs a scons subprocess with the 'graph' target. Returns process
96 exit code, 0 if ok."""
98 # -- Construct scons params with graph command info.
99 scons_params = self.construct_scons_params(
100 target_params=TargetParams(graph=graph_params),
101 verbosity=verbosity,
102 )
104 # -- Run the scons process.
105 return self._run_scons_subprocess("graph", scons_params=scons_params)
107 @on_exception(exit_code=1)
108 def lint(self, lint_params: LintParams) -> int:
109 """Runs a scons subprocess with the 'lint' target. Returns process
110 exit code, 0 if ok."""
112 # -- Construct scons params with graph command info.
113 scons_params = self.construct_scons_params(
114 target_params=TargetParams(lint=lint_params)
115 )
117 # -- Run the scons process.
118 return self._run_scons_subprocess("lint", scons_params=scons_params)
120 @on_exception(exit_code=1)
121 def sim(self, sim_params: SimParams) -> int:
122 """Runs a scons subprocess with the 'sim' target. Returns process
123 exit code, 0 if ok."""
125 # -- Construct scons params with graph command info.
126 scons_params = self.construct_scons_params(
127 target_params=TargetParams(sim=sim_params)
128 )
130 # -- Run the scons process.
131 return self._run_scons_subprocess("sim", scons_params=scons_params)
133 @on_exception(exit_code=1)
134 def test(self, test_params: ApioTestParams) -> int:
135 """Runs a scons subprocess with the 'test' target. Returns process
136 exit code, 0 if ok."""
138 # -- Construct scons params with graph command info.
139 scons_params = self.construct_scons_params(
140 target_params=TargetParams(test=test_params)
141 )
143 # -- Run the scons process.
144 return self._run_scons_subprocess("test", scons_params=scons_params)
146 @on_exception(exit_code=1)
147 def build(self, nextpnr_gui: bool, verbosity: Verbosity) -> int:
148 """Runs a scons subprocess with the 'build' target. Returns process
149 exit code, 0 if ok."""
151 # -- Construct the scons params object.
152 scons_params = self.construct_scons_params(
153 nextpnr_gui=nextpnr_gui,
154 verbosity=verbosity,
155 )
157 # -- Run the scons process.
158 return self._run_scons_subprocess("build", scons_params=scons_params)
160 @on_exception(exit_code=1)
161 def report(self, verbosity: Verbosity) -> int:
162 """Runs a scons subprocess with the 'report' target. Returns process
163 exit code, 0 if ok."""
165 # -- Construct the scons params object.
166 scons_params = self.construct_scons_params(verbosity=verbosity)
168 # -- Run the scons process.
169 return self._run_scons_subprocess("report", scons_params=scons_params)
171 @on_exception(exit_code=1)
172 def upload(self, upload_params: UploadParams) -> int:
173 """Runs a scons subprocess with the 'time' target. Returns process
174 exit code, 0 if ok.
175 """
177 # -- Construct the scons params.
178 scons_params = self.construct_scons_params(
179 target_params=TargetParams(upload=upload_params)
180 )
182 # -- Execute Scons for uploading!
183 exit_code = self._run_scons_subprocess(
184 "upload", scons_params=scons_params
185 )
187 return exit_code
189 def construct_scons_params(
190 self,
191 *,
192 target_params: TargetParams = None,
193 nextpnr_gui: bool = False,
194 verbosity: Verbosity = None,
195 ) -> SconsParams:
196 """Populate and return the SconsParam proto to pass to the scons
197 process."""
199 # -- Create a shortcut.
200 apio_ctx = self.apio_ctx
202 # -- Create an empty proto object that will be populated.
203 result = SconsParams()
205 # -- Set the nextpnr_gui if True.
206 if nextpnr_gui: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 result.nextpnr_gui = True
209 # -- Populate the timestamp. We use to to make sure scons reads the
210 # -- correct version of the scons.params file.
211 ts = datetime.now()
212 result.timestamp = ts.strftime("%d%H%M%S%f")[:-3]
214 # -- Get the project data. All commands that invoke scons are expected
215 # -- to be in a project context.
216 assert apio_ctx.has_project, "Scons encountered a missing project."
217 project = apio_ctx.project
219 # -- Get the project resources.
220 pr = apio_ctx.project_resources
221 fpga_info = pr.fpga_info
223 # -- Populate the common values of FpgaInfo.
224 result.fpga_info.MergeFrom(
225 FpgaInfo(
226 fpga_id=pr.fpga_id,
227 part_num=fpga_info["part-num"],
228 size=fpga_info["size"],
229 )
230 )
232 # - Populate the architecture specific values of result.fpga_info.
233 fpga_arch = fpga_info["arch"]
234 match fpga_arch:
235 case "ice40":
236 params = fpga_info["ice40-params"]
237 result.arch = ApioArch.ICE40
238 result.fpga_info.ice40_params.MergeFrom(
239 Ice40FpgaParams(
240 type=params["type"],
241 package=params["package"],
242 )
243 )
244 case "ecp5":
245 params = fpga_info["ecp5-params"]
246 result.arch = ApioArch.ECP5
247 result.fpga_info.ecp5_params.MergeFrom(
248 Ecp5FpgaParams(
249 type=params["type"],
250 package=params["package"],
251 speed=params["speed"],
252 )
253 )
254 case "gowin": 254 ↛ 264line 254 didn't jump to line 264 because the pattern on line 254 always matched
255 params = fpga_info["gowin-params"]
256 result.arch = ApioArch.GOWIN
257 result.fpga_info.gowin_params.MergeFrom(
258 GowinFpgaParams(
259 yosys_family=params["yosys-family"],
260 nextpnr_family=params["nextpnr-family"],
261 packer_device=params["packer-device"],
262 )
263 )
264 case _:
265 cerror(f"Unexpected fpga_arch value {fpga_arch}")
266 sys.exit(1)
268 # -- We are done populating The FpgaInfo params..
269 assert result.fpga_info.IsInitialized(), result
271 # -- Populate the optional Verbosity params.
272 if verbosity:
273 result.verbosity.MergeFrom(verbosity)
274 assert result.verbosity.IsInitialized(), result
276 # -- Populate the Environment params.
277 assert apio_ctx.platform_id, "Missing platform_id in apio context"
278 oss_set_vars = apio_ctx.all_packages["oss-cad-suite"]["env"][
279 "set-vars"
280 ]
281 assert "YOSYS_LIB" in oss_set_vars, oss_set_vars
282 assert "TRELLIS" in oss_set_vars, oss_set_vars
284 result.environment.MergeFrom(
285 Environment(
286 platform_id=apio_ctx.platform_id,
287 is_windows=apio_ctx.is_windows,
288 terminal_mode=(
289 FORCE_TERMINAL
290 if apio_console.is_terminal()
291 else FORCE_PIPE
292 ),
293 theme_name=apio_console.current_theme_name(),
294 debug_level=util.debug_level(),
295 yosys_path=oss_set_vars["YOSYS_LIB"],
296 trellis_path=oss_set_vars["TRELLIS"],
297 scons_shell_id=apio_ctx.scons_shell_id,
298 )
299 )
300 assert result.environment.IsInitialized(), result
302 # -- Populate the Project params.
303 result.apio_env_params.MergeFrom(
304 ApioEnvParams(
305 env_name=apio_ctx.project.env_name,
306 board_id=pr.board_id,
307 top_module=project.get_str_option("top-module"),
308 defines=apio_ctx.project.get_list_option(
309 "defines", default=[]
310 ),
311 yosys_extra_options=apio_ctx.project.get_list_option(
312 "yosys-extra-options", None
313 ),
314 nextpnr_extra_options=apio_ctx.project.get_list_option(
315 "nextpnr-extra-options", None
316 ),
317 gtkwave_extra_options=apio_ctx.project.get_list_option(
318 "gtkwave-extra-options", None
319 ),
320 verilator_extra_options=apio_ctx.project.get_list_option(
321 "verilator-extra-options", None
322 ),
323 constraint_file=apio_ctx.project.get_str_option(
324 "constraint-file", None
325 ),
326 )
327 )
328 assert result.apio_env_params.IsInitialized(), result
330 # -- Populate the optional command specific params.
331 if target_params:
332 result.target.MergeFrom(target_params)
333 assert result.target.IsInitialized(), result
335 # -- All done.
336 assert result.IsInitialized(), result
337 return result
339 def _run_scons_subprocess(
340 self, scons_command: str, *, scons_params: SconsParams = None
341 ):
342 """Invoke an scons subprocess."""
344 # pylint: disable=too-many-locals
346 # -- Create a shortcut.
347 apio_ctx = self.apio_ctx
349 # -- Pass to the scons process the name of the sconstruct file it
350 # -- should use.
351 scons_dir = util.get_path_in_apio_package("scons")
352 scons_file_path = scons_dir / "SConstruct"
353 variables = ["-f", f"{scons_file_path}"]
355 # -- Pass the path to the proto params file. The path is relative
356 # -- to the project root.
357 params_file_path = apio_ctx.env_build_path / "scons.params"
358 variables += [f"params={str(params_file_path)}"]
360 # -- Pass to the scons process the timestamp of the scons params we
361 # -- pass via a file. This is for verification purposes only.
362 variables += [f"timestamp={scons_params.timestamp}"]
364 # -- We set the env variables also for a command such as 'clean'
365 # -- which doesn't use the packages, to satisfy the required env
366 # -- variables of the scons arg parser.
367 apio_ctx.set_env_for_packages()
369 if util.is_debug(1): 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true
370 cout("\nSCONS CALL:", style=EMPH3)
371 cout(f"* command: {scons_command}")
372 cout(f"* variables: {variables}")
373 cout(f"* scons params: \n{scons_params}")
374 cout()
376 # -- Get the terminal width (typically 80)
377 terminal_width, _ = shutil.get_terminal_size()
379 # -- Read the time (for measuring how long does it take
380 # -- to execute the apio command)
381 start_time = time.time()
383 # -- Subtracting 1 to avoid line overflow on windows, Observed with
384 # -- Windows 10 and cmd.exe shell.
385 if apio_ctx.is_windows: 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true
386 terminal_width -= 1
388 # -- Print a horizontal line
389 cout("-" * terminal_width)
391 # -- Create the scons debug options. See details at
392 # -- https://scons.org/doc/2.4.1/HTML/scons-man.html
393 debug_options = (
394 ["--debug=explain,prepare,stacktrace", "--tree=all"]
395 if util.is_debug(1)
396 else []
397 )
399 # -- Construct the scons command line.
400 # --
401 # -- sys.executable is resolved to the full path of the python
402 # -- interpreter or to apio if running from a pyinstall setup.
403 # -- See https://github.com/orgs/pyinstaller/discussions/9023 for more
404 # -- information.
405 # --
406 # -- We use exec -m SCons instead of scones also for non pyinstaller
407 # -- deployment in case the scons binary is not on the PATH.
408 cmd = (
409 [sys.executable, "-m", "apio", "--scons"]
410 + ["-Q", scons_command]
411 + debug_options
412 + variables
413 )
415 # -- An output filter that manipulates the scons stdout/err lines as
416 # -- needed and write them to stdout.
417 scons_filter = SconsFilter(
418 colors_enabled=apio_console.is_colors_enabled()
419 )
421 # -- Write the scons parameters to a temp file in the build
422 # -- directory. It will be cleaned up as part of 'apio cleanup'.
423 # -- At this point, the project is the current directory, even if
424 # -- the command used the --project-dir option.
425 os.makedirs(apio_ctx.env_build_path, exist_ok=True)
426 with open(params_file_path, "w", encoding="utf8") as f:
427 f.write(text_format.MessageToString(scons_params))
429 if util.is_debug(1): 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true
430 cout(f"\nFull scons command: {cmd}\n\n")
432 # -- Execute the scons builder!
433 result = util.exec_command(
434 cmd,
435 stdout=util.AsyncPipe(scons_filter.on_stdout_line),
436 stderr=util.AsyncPipe(scons_filter.on_stderr_line),
437 )
439 # -- Is there an error? True/False
440 is_error = result.exit_code != 0
442 # -- Calculate the time it took to execute the command
443 duration = time.time() - start_time
445 # -- Determine status message
446 if is_error: 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true
447 styled_status = cstyle("ERROR", style=ERROR)
448 else:
449 styled_status = cstyle("SUCCESS", style=SUCCESS)
451 # -- Determine the summary text
452 summary = f"Took {duration:.2f} seconds"
454 # -- Construct the entire message.
455 styled_msg = f" [{styled_status}] {summary} "
456 msg_len = len(cunstyle(styled_msg))
458 # -- Determine the lengths of the paddings before and after
459 # -- the message. Should be correct for odd and even terminal
460 # -- widths.
461 pad1_len = (terminal_width - msg_len) // 2
462 pad2_len = terminal_width - pad1_len - msg_len
464 # -- Print the entire line.
465 cout(f"{'=' * pad1_len}{styled_msg}{'=' * pad2_len}")
467 # -- Return the exit code
468 return result.exit_code