Coverage for apio / managers / scons_manager.py: 80%
143 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-24 01:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-24 01:53 +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 Ice40FpgaInfo,
33 Ecp5FpgaInfo,
34 GowinFpgaInfo,
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 "Running with 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, 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(verbosity=verbosity)
154 # -- Run the scons process.
155 return self._run_scons_subprocess("build", scons_params=scons_params)
157 @on_exception(exit_code=1)
158 def report(self, verbosity: Verbosity) -> int:
159 """Runs a scons subprocess with the 'report' target. Returns process
160 exit code, 0 if ok."""
162 # -- Construct the scons params object.
163 scons_params = self.construct_scons_params(verbosity=verbosity)
165 # -- Run the scons process.
166 return self._run_scons_subprocess("report", scons_params=scons_params)
168 @on_exception(exit_code=1)
169 def upload(self, upload_params: UploadParams) -> int:
170 """Runs a scons subprocess with the 'time' target. Returns process
171 exit code, 0 if ok.
172 """
174 # -- Construct the scons params.
175 scons_params = self.construct_scons_params(
176 target_params=TargetParams(upload=upload_params)
177 )
179 # -- Execute Scons for uploading!
180 exit_code = self._run_scons_subprocess(
181 "upload", scons_params=scons_params
182 )
184 return exit_code
186 def construct_scons_params(
187 self,
188 *,
189 target_params: TargetParams = None,
190 verbosity: Verbosity = None,
191 ) -> SconsParams:
192 """Populate and return the SconsParam proto to pass to the scons
193 process."""
195 # -- Create a shortcut.
196 apio_ctx = self.apio_ctx
198 # -- Create an empty proto object that will be populated.
199 result = SconsParams()
201 # -- Populate the timestamp. We use to to make sure scons reads the
202 # -- correct version of the scons.params file.
203 ts = datetime.now()
204 result.timestamp = ts.strftime("%d%H%M%S%f")[:-3]
206 # -- Get the project data. All commands that invoke scons are expected
207 # -- to be in a project context.
208 assert apio_ctx.has_project, "Scons encountered a missing project."
209 project = apio_ctx.project
211 # -- Get the project resource.s
212 pr = apio_ctx.project_resources
214 # -- Populate the common values of FpgaInfo.
215 result.fpga_info.MergeFrom(
216 FpgaInfo(
217 fpga_id=pr.fpga_id,
218 part_num=pr.fpga_info["part-num"],
219 size=pr.fpga_info["size"],
220 )
221 )
223 # - Populate the architecture specific values of result.fpga_info.
224 fpga_arch = pr.fpga_info["arch"]
225 match fpga_arch:
226 case "ice40":
227 result.arch = ApioArch.ICE40
228 result.fpga_info.ice40.MergeFrom(
229 Ice40FpgaInfo(
230 type=pr.fpga_info["type"], pack=pr.fpga_info["pack"]
231 )
232 )
233 case "ecp5":
234 result.arch = ApioArch.ECP5
235 result.fpga_info.ecp5.MergeFrom(
236 Ecp5FpgaInfo(
237 type=pr.fpga_info["type"],
238 pack=pr.fpga_info["pack"],
239 speed=pr.fpga_info["speed"],
240 )
241 )
242 case "gowin": 242 ↛ 247line 242 didn't jump to line 247 because the pattern on line 242 always matched
243 result.arch = ApioArch.GOWIN
244 result.fpga_info.gowin.MergeFrom(
245 GowinFpgaInfo(family=pr.fpga_info["type"])
246 )
247 case _:
248 cerror(f"Unexpected fpga_arch value {fpga_arch}")
249 sys.exit(1)
251 # -- We are done populating The FpgaInfo params..
252 assert result.fpga_info.IsInitialized(), result
254 # -- Populate the optional Verbosity params.
255 if verbosity:
256 result.verbosity.MergeFrom(verbosity)
257 assert result.verbosity.IsInitialized(), result
259 # -- Populate the Environment params.
260 assert apio_ctx.platform_id, "Missing platform_id in apio context"
261 oss_vars = apio_ctx.all_packages["oss-cad-suite"]["env"]["vars"]
263 result.environment.MergeFrom(
264 Environment(
265 platform_id=apio_ctx.platform_id,
266 is_windows=apio_ctx.is_windows,
267 terminal_mode=(
268 FORCE_TERMINAL
269 if apio_console.is_terminal()
270 else FORCE_PIPE
271 ),
272 theme_name=apio_console.current_theme_name(),
273 debug_level=util.debug_level(),
274 yosys_path=oss_vars["YOSYS_LIB"],
275 trellis_path=oss_vars["TRELLIS"],
276 scons_shell_id=apio_ctx.scons_shell_id,
277 )
278 )
279 assert result.environment.IsInitialized(), result
281 # -- Populate the Project params.
282 result.apio_env_params.MergeFrom(
283 ApioEnvParams(
284 env_name=apio_ctx.project.env_name,
285 board_id=pr.board_id,
286 top_module=project.get_str_option("top-module"),
287 defines=apio_ctx.project.get_list_option(
288 "defines", default=[]
289 ),
290 yosys_synth_extra_options=apio_ctx.project.get_list_option(
291 "yosys-synth-extra-options", None
292 ),
293 nextpnr_extra_options=apio_ctx.project.get_list_option(
294 "nextpnr-extra-options", None
295 ),
296 constraint_file=apio_ctx.project.get_str_option(
297 "constraint-file", None
298 ),
299 )
300 )
301 assert result.apio_env_params.IsInitialized(), result
303 # -- Populate the optional command specific params.
304 if target_params:
305 result.target.MergeFrom(target_params)
306 assert result.target.IsInitialized(), result
308 # -- All done.
309 assert result.IsInitialized(), result
310 return result
312 def _run_scons_subprocess(
313 self, scons_command: str, *, scons_params: SconsParams = None
314 ):
315 """Invoke an scons subprocess."""
317 # pylint: disable=too-many-locals
319 # -- Create a shortcut.
320 apio_ctx = self.apio_ctx
322 # -- Pass to the scons process the name of the sconstruct file it
323 # -- should use.
324 scons_dir = util.get_path_in_apio_package("scons")
325 scons_file_path = scons_dir / "SConstruct"
326 variables = ["-f", f"{scons_file_path}"]
328 # -- Pass the path to the proto params file. The path is relative
329 # -- to the project root.
330 params_file_path = apio_ctx.env_build_path / "scons.params"
331 variables += [f"params={str(params_file_path)}"]
333 # -- Pass to the scons process the timestamp of the scons params we
334 # -- pass via a file. This is for verification purposes only.
335 variables += [f"timestamp={scons_params.timestamp}"]
337 # -- We set the env variables also for a command such as 'clean'
338 # -- which doesn't use the packages, to satisfy the required env
339 # -- variables of the scons arg parser.
340 apio_ctx.set_env_for_packages()
342 if util.is_debug(1): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 cout("\nSCONS CALL:", style=EMPH3)
344 cout(f"* command: {scons_command}")
345 cout(f"* variables: {variables}")
346 cout(f"* scons params: \n{scons_params}")
347 cout()
349 # -- Get the terminal width (typically 80)
350 terminal_width, _ = shutil.get_terminal_size()
352 # -- Read the time (for measuring how long does it take
353 # -- to execute the apio command)
354 start_time = time.time()
356 # -- Subtracting 1 to avoid line overflow on windows, Observed with
357 # -- Windows 10 and cmd.exe shell.
358 if apio_ctx.is_windows: 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true
359 terminal_width -= 1
361 # -- Print a horizontal line
362 cout("-" * terminal_width)
364 # -- Create the scons debug options. See details at
365 # -- https://scons.org/doc/2.4.1/HTML/scons-man.html
366 debug_options = (
367 ["--debug=explain,prepare,stacktrace", "--tree=all"]
368 if util.is_debug(1)
369 else []
370 )
372 # -- Construct the scons command line.
373 # --
374 # -- sys.executable is resolved to the full path of the python
375 # -- interpreter or to apio if running from a pyinstall setup.
376 # -- See https://github.com/orgs/pyinstaller/discussions/9023 for more
377 # -- information.
378 # --
379 # -- We use exec -m SCons instead of scones also for non pyinstaller
380 # -- deployment in case the scons binary is not on the PATH.
381 cmd = (
382 [sys.executable, "-m", "apio", "--scons"]
383 + ["-Q", scons_command]
384 + debug_options
385 + variables
386 )
388 # -- An output filter that manipulates the scons stdout/err lines as
389 # -- needed and write them to stdout.
390 scons_filter = SconsFilter(
391 colors_enabled=apio_console.is_colors_enabled()
392 )
394 # -- Write the scons parameters to a temp file in the build
395 # -- directory. It will be cleaned up as part of 'apio cleanup'.
396 # -- At this point, the project is the current directory, even if
397 # -- the command used the --project-dir option.
398 os.makedirs(apio_ctx.env_build_path, exist_ok=True)
399 with open(params_file_path, "w", encoding="utf8") as f:
400 f.write(text_format.MessageToString(scons_params))
402 if util.is_debug(1): 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true
403 cout(f"\nFull scons command: {cmd}\n\n")
405 # -- Execute the scons builder!
406 result = util.exec_command(
407 cmd,
408 stdout=util.AsyncPipe(scons_filter.on_stdout_line),
409 stderr=util.AsyncPipe(scons_filter.on_stderr_line),
410 )
412 # -- Is there an error? True/False
413 is_error = result.exit_code != 0
415 # -- Calculate the time it took to execute the command
416 duration = time.time() - start_time
418 # -- Determine status message
419 if is_error: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true
420 styled_status = cstyle("ERROR", style=ERROR)
421 else:
422 styled_status = cstyle("SUCCESS", style=SUCCESS)
424 # -- Determine the summary text
425 summary = f"Took {duration:.2f} seconds"
427 # -- Construct the entire message.
428 styled_msg = f" [{styled_status}] {summary} "
429 msg_len = len(cunstyle(styled_msg))
431 # -- Determine the lengths of the paddings before and after
432 # -- the message. Should be correct for odd and even terminal
433 # -- widths.
434 pad1_len = (terminal_width - msg_len) // 2
435 pad2_len = terminal_width - pad1_len - msg_len
437 # -- Print the entire line.
438 cout(f"{'=' * pad1_len}{styled_msg}{'=' * pad2_len}")
440 # -- Return the exit code
441 return result.exit_code