Coverage for apio/managers/project.py: 88%

175 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 05:08 +0000

1# This file is part of the Apio project 

2# (C) 2016-present FPGAwars 

3# Project authors: 

4# * Jesús Arroyo (Jesus89) (2016-2019) 

5# * Juan Gonzalez (obijuan) (2019-2024) 

6# * The Apio Team (2024-present) 

7# License: GPLv2 

8 

9"""Utility functionality for apio click commands.""" 

10 

11import re 

12from dataclasses import dataclass 

13import configparser 

14from collections import OrderedDict 

15from pathlib import Path 

16from typing import Any 

17from configobj import ConfigObj 

18from apio.common.debug_util import is_debug 

19from apio.common.apio_console import cout, fatal_error 

20from apio.common.apio_styles import SUCCESS, EMPH2 

21from apio.common.common_util import PROJECT_BUILD_PATH 

22from apio.common.proto.stubs.apio_definitions_pb2 import BoardDefinition 

23 

24DEFAULT_TOP_MODULE = "main" 

25 

26ENV_NAME_REGEX = re.compile(r"^[a-z][a-z0-9-]*$") 

27 

28ENV_NAME_HINT = ( 

29 "Env names should start with a-z, " 

30 "followed by any number of a-z, 0-9, and '-'." 

31) 

32 

33TOP_COMMENT = """\ 

34APIO project configuration file. 

35For details see https://fpgawars.github.io/apio/docs/project-file 

36""" 

37 

38# -- Apio options. These are the options that appear in the [apio] section. 

39# -- They are not subject to inheritance and resolution. 

40 

41 

42APIO_OPTIONS = [ 

43 # -- Selecting the env to use if not overridden in command line. Otherwise 

44 # -- the first env is the default. 

45 "default-env", 

46] 

47 

48 

49@dataclass(frozen=True) 

50class EnvOptionSpec: 

51 """Specifies a single apio.ini env option which can appear in an 

52 env section or the common section.""" 

53 

54 name: str 

55 is_required: bool = False 

56 is_list: bool = False 

57 

58 

59# -- Specification of the env options which can appear in env sections 

60# -- or the common section of apio.ini. 

61ENV_OPTIONS_SPEC = { 

62 "board": EnvOptionSpec( 

63 name="board", 

64 is_required=True, 

65 ), 

66 "top-module": EnvOptionSpec( 

67 name="top-module", 

68 is_required=True, 

69 ), 

70 "default-testbench": EnvOptionSpec( 

71 name="default-testbench", 

72 ), 

73 "defines": EnvOptionSpec( 

74 name="defines", 

75 is_list=True, 

76 ), 

77 "format-verible-options": EnvOptionSpec( 

78 name="format-verible-options", 

79 is_list=True, 

80 ), 

81 "programmer-cmd": EnvOptionSpec( 

82 name="programmer-cmd", 

83 ), 

84 "yosys-extra-options": EnvOptionSpec( 

85 name="yosys-extra-options", 

86 is_list=True, 

87 ), 

88 "nextpnr-extra-options": EnvOptionSpec( 

89 name="nextpnr-extra-options", 

90 is_list=True, 

91 ), 

92 "gtkwave-extra-options": EnvOptionSpec( 

93 name="gtkwave-extra-options", 

94 is_list=True, 

95 ), 

96 "verilator-extra-options": EnvOptionSpec( 

97 name="verilator-extra-options", 

98 is_list=True, 

99 ), 

100 "constraint-file": EnvOptionSpec( 

101 name="constraint-file", 

102 ), 

103} 

104 

105 

106class Project: 

107 """An instance of this class holds the information from the project's 

108 apio.ini file. 

109 """ 

110 

111 def __init__( 

112 self, 

113 *, 

114 apio_section: dict[str, Any], 

115 common_section: dict[str, Any], 

116 env_sections: dict[str, dict[str, Any]], 

117 env_arg: str | None, 

118 boards: dict[str, BoardDefinition], 

119 ): 

120 """Construct the project with information from apio.ini, command 

121 line arg, and boards resources.""" 

122 

123 # pylint: disable=too-many-arguments 

124 

125 if is_debug(1): 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 cout() 

127 cout("Parsed [apio] section:", style=EMPH2) 

128 cout(f" {apio_section}\n") 

129 cout("Parsed [common] section:", style=EMPH2) 

130 cout(f" {common_section}\n") 

131 for env_name, section_options in env_sections.items(): 

132 cout(f"Parsed [env:{env_name}] section:", style=EMPH2) 

133 cout(f"{section_options}\n") 

134 

135 # -- Validate the format of the env_arg value. 

136 if env_arg is not None: 

137 if not ENV_NAME_REGEX.match(env_arg): 

138 fatal_error( 

139 f"Invalid --env value '{env_arg}'.", 

140 info=ENV_NAME_HINT, 

141 ) 

142 

143 # -- Validate the apio.ini sections. We prefer to perform as much 

144 # -- validation as possible before we expand the env because the env 

145 # -- expansion may hide some options. 

146 Project._validate_all_sections( 

147 apio_section=apio_section, 

148 common_section=common_section, 

149 env_sections=env_sections, 

150 boards=boards, 

151 ) 

152 

153 # -- Keep the names of all envs 

154 self.env_names = list(env_sections.keys()) 

155 

156 # -- Determine the name of the active env. 

157 self.env_name = Project._determine_default_env_name( 

158 apio_section, env_sections, env_arg 

159 ) 

160 

161 # -- Expand and selected env options. This is also patches default 

162 # -- values and validates the results. 

163 self.env_options: dict[str, str | list[str]] = ( 

164 Project._parse_env_options( 

165 env_name=self.env_name, 

166 common_section=common_section, 

167 env_sections=env_sections, 

168 ) 

169 ) 

170 if is_debug(1): 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 cout("Selected env name:", style=EMPH2) 

172 cout(f" {self.env_name}\n") 

173 cout("Expanded env options:", style=EMPH2) 

174 cout(f" {self.env_options}\n") 

175 

176 @staticmethod 

177 def _validate_all_sections( 

178 apio_section: dict[str, str], 

179 common_section: dict, 

180 env_sections: dict[str, dict[str, str]], 

181 boards: dict[str, BoardDefinition], 

182 ): 

183 """Validate the parsed apio.ini sections.""" 

184 

185 # -- Validate the common section. 

186 Project._validate_env_section("[common]", common_section, boards) 

187 

188 # -- Validate the env sections. 

189 if not env_sections: 

190 fatal_error( 

191 "Project file 'apio.ini' should have at least one " 

192 + "[env:name] section." 

193 ) 

194 

195 for env_name, section_options in env_sections.items(): 

196 # -- Validate env name format. 

197 if not ENV_NAME_REGEX.match(env_name): 

198 fatal_error( 

199 f"Invalid env name '{env_name}' in apio.ini.", 

200 info=ENV_NAME_HINT, 

201 ) 

202 

203 # -- Validate env section options. 

204 Project._validate_env_section( 

205 f"[env:{env_name}]", section_options, boards 

206 ) 

207 

208 # -- Validate the apio section. At this point the env_sections are 

209 # -- already validated. 

210 Project._validate_apio_section(apio_section, env_sections) 

211 

212 @staticmethod 

213 def _validate_apio_section( 

214 apio_section: dict[str, str], env_sections: dict[str, dict[str, str]] 

215 ): 

216 """Validate the [apio] section. 'env_sections' are assumed to be 

217 validated.""" 

218 

219 # -- Look for unknown options. 

220 for option in apio_section: 

221 if option not in APIO_OPTIONS: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 fatal_error( 

223 f"Unknown option '{option} in [apio] section of apio.ini'" 

224 ) 

225 

226 # -- If 'default-env' option exists, verify the env name is valid and 

227 # -- and the name exists. 

228 default_env_name = apio_section.get("default-env", None) 

229 if default_env_name: 

230 # -- Validate env name format. 

231 if not ENV_NAME_REGEX.match(default_env_name): 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 fatal_error( 

233 f"Invalid default env name '{default_env_name}' " 

234 + "in apio.ini.", 

235 info=ENV_NAME_HINT, 

236 ) 

237 # -- Make sure the env exists. 

238 if default_env_name not in env_sections: 

239 fatal_error( 

240 f"Env '{default_env_name}' not found in apio.ini.", 

241 info=f"Expecting an env section '{default_env_name}' " 

242 + "in apio.ini", 

243 ) 

244 

245 @staticmethod 

246 def _validate_env_section( 

247 section_title: str, 

248 section_options: dict[str, str], 

249 boards: dict[str, BoardDefinition], 

250 ): 

251 """Validate the options of a section that contains env options. This 

252 includes the sections [env:*] and [common].""" 

253 

254 # -- Check that there are no unknown options. 

255 for option in section_options: 

256 if option not in ENV_OPTIONS_SPEC: 

257 fatal_error( 

258 f"Unknown option '{option}' in {section_title} " 

259 + "section of apio.ini." 

260 ) 

261 

262 # -- If 'board' option exists, verify that the board exists. 

263 board_id = section_options.get("board", None) 

264 if board_id is not None and board_id not in boards: 

265 fatal_error(f"Unknown board id '{board_id}' in apio.ini.") 

266 

267 @staticmethod 

268 def _determine_default_env_name( 

269 apio_section: dict[str, str], 

270 env_sections: dict[str, dict[str, str]], 

271 env_arg: str | None, 

272 ) -> str: 

273 """Determines the active env name. Sections are assumed to be 

274 validated. 'env_arg' is the value of the optional command line --env 

275 which allows the user to select the env.""" 

276 # -- Priority #1 (highest): User specified env name in the command. 

277 env_name = env_arg 

278 

279 # -- Priority #2: The optional default-env option in the 

280 # -- [apio] section. 

281 if env_name is None: 

282 env_name = apio_section.get("default-env", None) 

283 

284 # -- Priority #3 (lowest): Picking the first env defined in apio.ini. 

285 # -- Note that the envs order is preserved in env_sections. 

286 if env_name is None: 

287 # -- The env sections preserve the order in apio.ini. 

288 env_name = list(env_sections.keys())[0] 

289 

290 # -- Error if the env doesn't exist. 

291 if env_name not in env_sections: 

292 fatal_error( 

293 f"Env '{env_name}' not found in apio.ini.", 

294 info=f"Expecting an env section '[env:{env_name}] in apio.ini", 

295 ) 

296 

297 # -- All done. 

298 return env_name 

299 

300 @staticmethod 

301 def _expand_value(s: str, macros: dict[str, str]) -> str: 

302 """Expand macros by replacing macros keys with macro values.""" 

303 for k, v in macros.items(): 

304 s = s.replace(k, v) 

305 return s 

306 

307 @staticmethod 

308 def _parse_env_options( 

309 env_name: str, 

310 common_section: dict, 

311 env_sections: dict[str, dict[str, str | list[str]]], 

312 ) -> dict[str, str | list[str]]: 

313 """Expand the options of given env name. The given common and envs 

314 sections are already validate. String options are returned as strings 

315 and list options are returned as list of strings. 

316 """ 

317 

318 # -- Key/Value dict for macro expansion. 

319 macros = { 

320 # -- The ';' char. (de-conflicted from ; comment) 

321 "${SEMICOLON}": ";", 

322 # -- The '#' char. (de-conflicted from # comment) 

323 "${HASH}": "#", 

324 # -- The env name. 

325 "${ENV_NAME}": env_name, 

326 # -- The relative path to env build directory (linux / style) 

327 "${ENV_BUILD}": (PROJECT_BUILD_PATH / env_name).as_posix(), 

328 } 

329 

330 # -- Select the env section by name. 

331 env_section = env_sections[env_name] 

332 

333 # -- Create an empty result dict. 

334 # -- We will insert to it the relevant options by the oder they appear 

335 # -- in apio.ini. 

336 result: dict[str, str | list[str]] = {} 

337 

338 # -- Add common options that are not in env section 

339 for name, val in common_section.items(): 

340 if name not in env_section: 340 ↛ 339line 340 didn't jump to line 339 because the condition on line 340 was always true

341 result[name] = Project._expand_value(val, macros) 

342 

343 # -- Add all the options from the env section. 

344 for name, val in env_section.items(): 

345 result[name] = Project._expand_value(str(val), macros) 

346 

347 # -- check that all the required options exist. 

348 for option_spec in ENV_OPTIONS_SPEC.values(): 

349 if option_spec.is_required and option_spec.name not in result: 

350 fatal_error( 

351 f"Missing required option '{option_spec.name}' " 

352 + f"for env '{env_name}'." 

353 ) 

354 

355 # -- Convert the list options from strings to list. 

356 for name, str_val in result.items(): 

357 list_option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(name) 

358 if list_option_spec and list_option_spec.is_list: 

359 if isinstance(str_val, str): 359 ↛ 356line 359 didn't jump to line 356 because the condition on line 359 was always true

360 list_val = str_val.split("\n") 

361 # -- Select the non empty items. 

362 list_val = [x for x in list_val if x] 

363 result[name] = list_val 

364 

365 return result 

366 

367 def get_str_option(self, option: str, default: Any = None) -> str | Any: 

368 """Lookup an env option value by name. Returns default if not found.""" 

369 

370 # -- If this fails, this is a programming error. 

371 option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(option, None) 

372 assert option_spec, f"Invalid env option: [{option}]" 

373 assert not option_spec.is_list, f"Not a simple str option: {option}" 

374 

375 # -- Lookup with default 

376 value = self.env_options.get(option, None) 

377 

378 if value is None: 

379 return default 

380 

381 assert isinstance(value, str) 

382 return value 

383 

384 def get_list_option( 

385 self, option: str, default: Any = None 

386 ) -> list[str] | Any: 

387 """Lookup an env option value that has a line list format. Returns 

388 the list of non empty lines or default if no value. Option 

389 must be in OPTIONS.""" 

390 

391 # -- If this fails, this is a programming error. 

392 option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(option, None) 

393 assert option_spec, f"Invalid env option: [{option}]" 

394 assert option_spec.is_list, f"Not a list option: {option}" 

395 

396 # -- Get the option values, it's is expected to be a list of str. 

397 values_list = self.env_options.get(option, None) 

398 

399 # -- If not found, return default 

400 if values_list is None: 

401 return default 

402 

403 # -- Return the list 

404 assert isinstance(values_list, list), values_list 

405 return values_list 

406 

407 

408def load_project_from_file( 

409 project_dir: Path, 

410 env_arg: str | None, 

411 boards: dict[str, BoardDefinition], 

412) -> Project: 

413 """Read project file from given project dir. Returns None if file 

414 does not exists. Exits on any error. Otherwise creates adn 

415 return an Project with the values. To validate the project object 

416 call its validate() method.""" 

417 

418 # -- Construct the apio.ini path. 

419 file_path = project_dir / "apio.ini" 

420 

421 # -- Currently, apio.ini is still optional so we just warn. 

422 if not file_path.exists(): 

423 fatal_error( 

424 "Missing project file apio.ini.", 

425 f"Expected a file at '{file_path.absolute()}'", 

426 ) 

427 

428 # -- Read and parse the file. 

429 # -- By using OrderedDict we cause the parser to preserve the order of 

430 # -- options in a section. The order of sections is already preserved by 

431 # -- default. 

432 parser = configparser.ConfigParser(dict_type=OrderedDict) 

433 try: 

434 parser.read(file_path) 

435 except configparser.Error as e: 

436 fatal_error(cause=e) 

437 

438 # -- Iterate and collect the sections in the order they appear in 

439 # -- the apio.ini file. Section names are guaranteed to be unique with 

440 # -- no duplicates. 

441 sections_names = parser.sections() 

442 

443 apio_section: dict[str, Any] = {} 

444 common_section: dict[str, Any] = {} 

445 env_sections: dict[str, dict[str, Any]] = {} 

446 

447 common_section_found: bool = False 

448 env_sections_found: bool = False 

449 

450 for section_name in sections_names: 

451 # -- Handle the [apio[ section.]] 

452 if section_name == "apio": 

453 if common_section_found or env_sections_found: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 fatal_error("The [apio] section must be the first section.") 

455 apio_section = dict(parser.items(section_name)) 

456 continue 

457 

458 # -- Handle the [common] section. 

459 if section_name == "common": 

460 if env_sections_found: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 fatal_error( 

462 "The [common] section must be before [env:] sections." 

463 ) 

464 common_section = dict(parser.items(section_name)) 

465 common_section_found = True 

466 continue 

467 

468 # -- A bare [env] section is no longer accepted. 

469 if section_name == "env": 

470 fatal_error( 

471 "Invalid section name 'env' in apio.ini.", 

472 info="Rename it to [env:default].", 

473 ) 

474 

475 # -- Handle the [env:env-name] sections. 

476 tokes = section_name.split(":") 

477 if len(tokes) == 2 and tokes[0] == "env": 477 ↛ 483line 477 didn't jump to line 483 because the condition on line 477 was always true

478 env_name = tokes[1] 

479 env_sections[env_name] = dict(parser.items(section_name)) 

480 continue 

481 

482 # -- Handle unknown section name. 

483 fatal_error( 

484 f"Invalid section name '{section_name}' in apio.ini.", 

485 info="The valid section names are [apio], [common], " 

486 + "and [env:env-name]", 

487 ) 

488 

489 # -- Construct the Project object. Its constructor validates the options. 

490 return Project( 

491 apio_section=apio_section, 

492 common_section=common_section, 

493 env_sections=env_sections, 

494 env_arg=env_arg, 

495 boards=boards, 

496 ) 

497 

498 

499def create_project_file( 

500 project_dir: Path, 

501 board_id: str, 

502 top_module: str, 

503): 

504 """Creates a new basic apio project file. Exits on any error.""" 

505 

506 # -- Construct the path 

507 ini_path = project_dir / "apio.ini" 

508 

509 # -- Error if apio.ini already exists. 

510 if ini_path.exists(): 

511 fatal_error("The file apio.ini already exists.") 

512 

513 # -- Construct and write the apio.ini file.. 

514 cout(f"Creating {ini_path} file ...") 

515 

516 section_name = "env:default" 

517 

518 config = ConfigObj(str(ini_path)) 

519 config.initial_comment = TOP_COMMENT.split("\n") 

520 config[section_name] = {"board": board_id, "top-module": top_module} 

521 config.write() 

522 cout(f"The file '{ini_path}' was created successfully.", style=SUCCESS)