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

211 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-24 03:51 +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"""Utility functionality for apio click commands.""" 

11 

12import sys 

13import re 

14from dataclasses import dataclass 

15import configparser 

16from collections import OrderedDict 

17from pathlib import Path 

18from typing import Dict, Optional, Union, Any, List 

19from configobj import ConfigObj 

20from apio.utils import util 

21from apio.common.apio_console import cout, cerror, cwarning 

22from apio.common.apio_styles import INFO, SUCCESS, EMPH2 

23from apio.common.common_util import PROJECT_BUILD_PATH 

24 

25 

26DEFAULT_TOP_MODULE = "main" 

27 

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

29 

30ENV_NAME_HINT = ( 

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

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

33) 

34 

35TOP_COMMENT = """\ 

36APIO project configuration file. 

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

38""" 

39 

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

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

42 

43 

44APIO_OPTIONS = [ 

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

46 # -- the first env is the default. 

47 "default-env", 

48] 

49 

50 

51@dataclass(frozen=True) 

52class EnvOptionSpec: 

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

54 env section or the common section.""" 

55 

56 name: str 

57 is_required: bool = False 

58 is_list: bool = False 

59 

60 

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

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

63ENV_OPTIONS_SPEC = { 

64 "board": EnvOptionSpec( 

65 name="board", 

66 is_required=True, 

67 ), 

68 "top-module": EnvOptionSpec( 

69 name="top-module", 

70 is_required=True, 

71 ), 

72 "default-testbench": EnvOptionSpec( 

73 name="default-testbench", 

74 ), 

75 "defines": EnvOptionSpec( 

76 name="defines", 

77 is_list=True, 

78 ), 

79 "format-verible-options": EnvOptionSpec( 

80 name="format-verible-options", 

81 is_list=True, 

82 ), 

83 "programmer-cmd": EnvOptionSpec( 

84 name="programmer-cmd", 

85 ), 

86 "yosys-extra-options": EnvOptionSpec( 

87 name="yosys-extra-options", 

88 is_list=True, 

89 ), 

90 "nextpnr-extra-options": EnvOptionSpec( 

91 name="nextpnr-extra-options", 

92 is_list=True, 

93 ), 

94 "gtkwave-extra-options": EnvOptionSpec( 

95 name="gtkwave-extra-options", 

96 is_list=True, 

97 ), 

98 "verilator-extra-options": EnvOptionSpec( 

99 name="verilator-extra-options", 

100 is_list=True, 

101 ), 

102 "constraint-file": EnvOptionSpec( 

103 name="constraint-file", 

104 ), 

105} 

106 

107 

108class Project: 

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

110 apio.ini file. 

111 """ 

112 

113 def __init__( 

114 self, 

115 *, 

116 apio_section: Dict[str, str], 

117 common_section: Dict[str, Dict], 

118 env_sections: Dict[str, Dict], 

119 env_arg: str | None, 

120 boards: Dict[str, Dict], 

121 ): 

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

123 line arg, and boards resources.""" 

124 

125 # pylint: disable=too-many-arguments 

126 

127 if util.is_debug(1): 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 cout() 

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

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

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

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

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

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

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

136 

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

138 if env_arg is not None: 

139 if not ENV_NAME_REGEX.match(env_arg): 

140 cerror(f"Invalid --env value '{env_arg}'.") 

141 cout(ENV_NAME_HINT, style=INFO) 

142 sys.exit(1) 

143 

144 # -- Patch legacy board ids in the common and env sections. 

145 Project._patch_legacy_board_id( 

146 common_section, boards # pyright: ignore[reportArgumentType] 

147 ) 

148 for section_options in env_sections.values(): 

149 Project._patch_legacy_board_id(section_options, boards) 

150 

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

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

153 # -- expansion may hide some options. 

154 Project._validate_all_sections( 

155 apio_section=apio_section, 

156 common_section=common_section, 

157 env_sections=env_sections, 

158 boards=boards, 

159 ) 

160 

161 # -- Keep the names of all envs 

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

163 

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

165 self.env_name = Project._determine_default_env_name( 

166 apio_section, env_sections, env_arg 

167 ) 

168 

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

170 # -- values and validates the results. 

171 self.env_options: Dict[str, Union[str, List[str]]] = ( 

172 Project._parse_env_options( 

173 env_name=self.env_name, 

174 common_section=common_section, 

175 env_sections=env_sections, 

176 ) 

177 ) 

178 if util.is_debug(1): 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true

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

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

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

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

183 

184 @staticmethod 

185 def _patch_legacy_board_id( 

186 section_options: Dict[str, str], boards: Dict[str, Dict] 

187 ) -> Optional[str]: 

188 """Temporary patching of old board ids to new in an env or common 

189 section. If there is a "board" option with a legacy board id, 

190 then change it to the board's canonical name. Otherwise, leave the 

191 options as are.""" 

192 

193 # -- Get the value of the "board" option. 

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

195 

196 # -- Nothing to do if no "board" option. 

197 if board_id is None: 

198 return 

199 

200 # -- Nothing to do if board_id is in the boards dict. It's 

201 # -- a good (new style) board id. 

202 if board_id in boards: 

203 return 

204 

205 # -- Iterate the boards and if board_id matches the legacy name of 

206 # -- a board, change the "board" option to the canonical name of that 

207 # -- board. 

208 for canonical_id, board_info in boards.items(): 

209 if board_id == board_info.get("legacy-name", None): 

210 section_options["board"] = canonical_id 

211 cwarning( 

212 f"'Board {board_id}' was renamed to '{canonical_id}'. " 

213 "Please update apio.ini." 

214 ) 

215 return 

216 

217 @staticmethod 

218 def _validate_all_sections( 

219 apio_section: Dict[str, str], 

220 common_section: Dict, 

221 env_sections: Dict[str, Dict[str, str]], 

222 boards: Dict[str, Dict], 

223 ): 

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

225 

226 # -- Validate the common section. 

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

228 

229 # -- Validate the env sections. 

230 if not env_sections: 

231 cerror( 

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

233 "[env:name] section." 

234 ) 

235 sys.exit(1) 

236 

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

238 # -- Validate env name format. 

239 if not ENV_NAME_REGEX.match(env_name): 

240 cerror(f"Invalid env name '{env_name}' in apio.ini.") 

241 cout(ENV_NAME_HINT, style=INFO) 

242 sys.exit(1) 

243 # -- Validate env section options. 

244 Project._validate_env_section( 

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

246 ) 

247 

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

249 # -- already validated. 

250 Project._validate_apio_section(apio_section, env_sections) 

251 

252 @staticmethod 

253 def _validate_apio_section( 

254 apio_section: Dict[str, str], env_sections: Dict[str, Dict[str, str]] 

255 ): 

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

257 validated.""" 

258 

259 # -- Look for unknown options. 

260 for option in apio_section: 

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

262 cerror( 

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

264 ) 

265 sys.exit(1) 

266 

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

268 # -- and the name exists. 

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

270 if default_env_name: 

271 # -- Validate env name format. 

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

273 cerror( 

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

275 "in apio.ini." 

276 ) 

277 cout(ENV_NAME_HINT, style=INFO) 

278 sys.exit(1) 

279 # -- Make sure the env exists. 

280 if default_env_name not in env_sections: 

281 cerror(f"Env '{default_env_name}' not found in apio.ini.") 

282 cout( 

283 f"Expecting an env section '{default_env_name}' " 

284 "in apio.ini", 

285 style=INFO, 

286 ) 

287 sys.exit(1) 

288 

289 @staticmethod 

290 def _validate_env_section( 

291 section_title: str, 

292 section_options: Dict[str, str], 

293 boards: Dict[str, Dict], 

294 ): 

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

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

297 

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

299 for option in section_options: 

300 if option not in ENV_OPTIONS_SPEC: 

301 cerror( 

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

303 "section of apio.ini." 

304 ) 

305 sys.exit(1) 

306 

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

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

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

310 cerror(f"Unknown board id '{board_id}' in apio.ini.") 

311 sys.exit(1) 

312 

313 @staticmethod 

314 def _determine_default_env_name( 

315 apio_section: Dict[str, str], 

316 env_sections: Dict[str, Dict[str, str]], 

317 env_arg: Optional[str], 

318 ) -> str: 

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

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

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

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

323 env_name = env_arg 

324 

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

326 # -- [apio] section. 

327 if env_name is None: 

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

329 

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

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

332 if env_name is None: 

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

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

335 

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

337 if env_name not in env_sections: 

338 cerror(f"Env '{env_name}' not found in apio.ini.") 

339 cout( 

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

341 style=INFO, 

342 ) 

343 sys.exit(1) 

344 

345 # -- All done. 

346 return env_name 

347 

348 @staticmethod 

349 def _expand_value(s: str, macros: Dict[str, str]) -> str: 

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

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

352 s = s.replace(k, v) 

353 return s 

354 

355 @staticmethod 

356 def _parse_env_options( 

357 env_name: str, 

358 common_section: Dict, 

359 env_sections: Dict[str, Dict[str, Union[str, List[str]]]], 

360 ) -> Dict[str, Union[str, List[str]]]: 

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

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

363 and list options are returned as list of strings. 

364 """ 

365 

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

367 macros = { 

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

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

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

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

372 # -- The env name. 

373 "${ENV_NAME}": env_name, 

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

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

376 } 

377 

378 # -- Select the env section by name. 

379 env_section = env_sections[env_name] 

380 

381 # -- Create an empty result dict. 

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

383 # -- in apio.ini. 

384 result: Dict[str, Union[str, List[str]]] = {} 

385 

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

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

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

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

390 

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

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

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

394 

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

396 for option_spec in ENV_OPTIONS_SPEC.values(): 

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

398 cerror( 

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

400 f"for env '{env_name}'." 

401 ) 

402 sys.exit(1) 

403 

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

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

406 option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(name) 

407 if option_spec and option_spec.is_list: 

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

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

410 # -- Select the non empty items. 

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

412 result[name] = list_val 

413 

414 return result 

415 

416 def get_str_option( 

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

418 ) -> Union[str, Any]: 

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

420 

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

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

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

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

425 

426 # -- Lookup with default 

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

428 

429 if value is None: 

430 return default 

431 

432 assert isinstance(value, str) 

433 return value 

434 

435 def get_list_option( 

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

437 ) -> Union[List[str], Any]: 

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

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

440 must be in OPTIONS.""" 

441 

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

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

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

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

446 

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

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

449 

450 # -- If not found, return default 

451 if values_list is None: 

452 return default 

453 

454 # -- Return the list 

455 assert isinstance(values_list, list), values_list 

456 return values_list 

457 

458 

459def load_project_from_file( 

460 project_dir: Path, env_arg: Optional[str], boards: Dict[str, Dict] 

461) -> Project: 

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

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

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

465 call its validate() method.""" 

466 

467 # -- Construct the apio.ini path. 

468 file_path = project_dir / "apio.ini" 

469 

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

471 if not file_path.exists(): 

472 cerror( 

473 "Missing project file apio.ini.", 

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

475 ) 

476 sys.exit(1) 

477 

478 # -- Read and parse the file. 

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

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

481 # -- default. 

482 parser = configparser.ConfigParser(dict_type=OrderedDict) 

483 try: 

484 parser.read(file_path) 

485 except configparser.Error as e: 

486 cerror(str(e)) 

487 sys.exit(1) 

488 

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

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

491 # -- no duplicates. 

492 sections_names = parser.sections() 

493 

494 apio_section = None 

495 common_section = None 

496 env_sections = {} 

497 

498 for section_name in sections_names: 

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

500 if section_name == "apio": 

501 if common_section or env_sections: 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 cerror("The [apio] section must be the first section.") 

503 sys.exit(1) 

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

505 continue 

506 

507 # -- Handle the [common] section. 

508 if section_name == "common": 

509 if env_sections: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 cerror("The [common] section must be before [env:] sections.") 

511 sys.exit(1) 

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

513 continue 

514 

515 # TODO: Remove this option after this is released. 

516 # -- Handle the legacy [env] section. 

517 if section_name == "env" and len(sections_names) == 1: 

518 # env_sections["default"] = parser.items(section_name) 

519 cwarning( 

520 "Apio.ini has a legacy [env] section. " 

521 "Please rename it to [env:default]." 

522 ) 

523 env_sections["default"] = dict(parser.items(section_name)) 

524 continue 

525 

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

527 tokes = section_name.split(":") 

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

529 env_name = tokes[1] 

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

531 continue 

532 

533 # -- Handle unknown section name. 

534 cerror(f"Invalid section name '{section_name}' in apio.ini.") 

535 cout( 

536 "The valid section names are [apio], [common], and [env:env-name]", 

537 style=INFO, 

538 ) 

539 sys.exit(1) 

540 

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

542 return Project( 

543 apio_section=apio_section or {}, 

544 common_section=( 

545 common_section or {} 

546 ), # pyright: ignore[reportArgumentType] 

547 env_sections=env_sections, 

548 env_arg=env_arg, 

549 boards=boards, 

550 ) 

551 

552 

553def create_project_file( 

554 project_dir: Path, 

555 board_id: str, 

556 top_module: str, 

557): 

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

559 

560 # -- Construct the path 

561 ini_path = project_dir / "apio.ini" 

562 

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

564 if ini_path.exists(): 

565 cerror("The file apio.ini already exists.") 

566 sys.exit(1) 

567 

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

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

570 

571 section_name = "env:default" 

572 

573 config = ConfigObj(str(ini_path)) 

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

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

576 config.write() 

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