Coverage for apio/managers/scons_manager.py: 80%
143 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 10:20 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 10:20 +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 )
277 )
278 assert result.environment.IsInitialized(), result
280 # -- Populate the Project params.
281 result.apio_env_params.MergeFrom(
282 ApioEnvParams(
283 env_name=apio_ctx.project.env_name,
284 board_id=pr.board_id,
285 top_module=project.get_str_option("top-module"),
286 defines=apio_ctx.project.get_list_option(
287 "defines", default=[]
288 ),
289 yosys_synth_extra_options=apio_ctx.project.get_list_option(
290 "yosys-synth-extra-options", None
291 ),
292 nextpnr_extra_options=apio_ctx.project.get_list_option(
293 "nextpnr-extra-options", None
294 ),
295 constraint_file=apio_ctx.project.get_str_option(
296 "constraint-file", None
297 ),
298 )
299 )
300 assert result.apio_env_params.IsInitialized(), result
302 # -- Populate the optional command specific params.
303 if target_params:
304 result.target.MergeFrom(target_params)
305 assert result.target.IsInitialized(), result
307 # -- All done.
308 assert result.IsInitialized(), result
309 return result
311 def _run_scons_subprocess(
312 self, scons_command: str, *, scons_params: SconsParams = None
313 ):
314 """Invoke an scons subprocess."""
316 # pylint: disable=too-many-locals
318 # -- Create a shortcut.
319 apio_ctx = self.apio_ctx
321 # -- Pass to the scons process the name of the sconstruct file it
322 # -- should use.
323 scons_dir = util.get_path_in_apio_package("scons")
324 scons_file_path = scons_dir / "SConstruct"
325 variables = ["-f", f"{scons_file_path}"]
327 # -- Pass the path to the proto params file. The path is relative
328 # -- to the project root.
329 params_file_path = apio_ctx.env_build_path / "scons.params"
330 variables += [f"params={str(params_file_path)}"]
332 # -- Pass to the scons process the timestamp of the scons params we
333 # -- pass via a file. This is for verification purposes only.
334 variables += [f"timestamp={scons_params.timestamp}"]
336 # -- We set the env variables also for a command such as 'clean'
337 # -- which doesn't use the packages, to satisfy the required env
338 # -- variables of the scons arg parser.
339 apio_ctx.set_env_for_packages()
341 if util.is_debug(1): 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 cout("\nSCONS CALL:", style=EMPH3)
343 cout(f"* command: {scons_command}")
344 cout(f"* variables: {variables}")
345 cout(f"* scons params: \n{scons_params}")
346 cout()
348 # -- Get the terminal width (typically 80)
349 terminal_width, _ = shutil.get_terminal_size()
351 # -- Read the time (for measuring how long does it take
352 # -- to execute the apio command)
353 start_time = time.time()
355 # -- Subtracting 1 to avoid line overflow on windows, Observed with
356 # -- Windows 10 and cmd.exe shell.
357 if apio_ctx.is_windows: 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 terminal_width -= 1
360 # -- Print a horizontal line
361 cout("-" * terminal_width)
363 # -- Create the scons debug options. See details at
364 # -- https://scons.org/doc/2.4.1/HTML/scons-man.html
365 debug_options = (
366 ["--debug=explain,prepare,stacktrace", "--tree=all"]
367 if util.is_debug(1)
368 else []
369 )
371 # -- Construct the scons command line.
372 # --
373 # -- sys.executable is resolved to the full path of the python
374 # -- interpreter or to apio if running from a pyinstall setup.
375 # -- See https://github.com/orgs/pyinstaller/discussions/9023 for more
376 # -- information.
377 # --
378 cmd = (
379 [sys.executable, "-m", "SCons"]
380 + ["-Q", scons_command]
381 + debug_options
382 + variables
383 )
385 # -- An output filter that manipulates the scons stdout/err lines as
386 # -- needed and write them to stdout.
387 scons_filter = SconsFilter(
388 colors_enabled=apio_console.is_colors_enabled()
389 )
391 # -- Write the scons parameters to a temp file in the build
392 # -- directory. It will be cleaned up as part of 'apio cleanup'.
393 # -- At this point, the project is the current directory, even if
394 # -- the command used the --project-dir option.
395 os.makedirs(apio_ctx.env_build_path, exist_ok=True)
396 with open(params_file_path, "w", encoding="utf8") as f:
397 f.write(text_format.MessageToString(scons_params))
399 if util.is_debug(1): 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true
400 cout(f"\nFull scons command: {cmd}\n\n")
402 # -- Execute the scons builder!
403 result = util.exec_command(
404 cmd,
405 stdout=util.AsyncPipe(scons_filter.on_stdout_line),
406 stderr=util.AsyncPipe(scons_filter.on_stderr_line),
407 )
409 # -- Is there an error? True/False
410 is_error = result.exit_code != 0
412 # -- Calculate the time it took to execute the command
413 duration = time.time() - start_time
415 # -- Determine status message
416 if is_error: 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 styled_status = cstyle("ERROR", style=ERROR)
418 else:
419 styled_status = cstyle("SUCCESS", style=SUCCESS)
421 # -- Determine the summary text
422 summary = f"Took {duration:.2f} seconds"
424 # -- Construct the entire message.
425 styled_msg = f" [{styled_status}] {summary} "
426 msg_len = len(cunstyle(styled_msg))
428 # -- Determine the lengths of the paddings before and after
429 # -- the message. Should be correct for odd and even terminal
430 # -- widths.
431 pad1_len = (terminal_width - msg_len) // 2
432 pad2_len = terminal_width - pad1_len - msg_len
434 # -- Print the entire line.
435 cout(f"{'=' * pad1_len}{styled_msg}{'=' * pad2_len}")
437 # -- Return the exit code
438 return result.exit_code