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

212 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-25 02:31 +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: Optional[Dict[str, str]], 

117 common_section: Optional[Dict[str, str]], 

118 env_sections: Dict[str, Dict[str, str]], 

119 env_arg: Optional[str], 

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(common_section, boards) 

146 for section_options in env_sections.values(): 

147 Project._patch_legacy_board_id(section_options, boards) 

148 

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

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

151 # -- expansion may hide some options. 

152 Project._validate_all_sections( 

153 apio_section=apio_section, 

154 common_section=common_section, 

155 env_sections=env_sections, 

156 boards=boards, 

157 ) 

158 

159 # -- Keep the names of all envs 

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

161 

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

163 self.env_name = Project._determine_default_env_name( 

164 apio_section, env_sections, env_arg 

165 ) 

166 

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

168 # -- values and validates the results. 

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

170 Project._parse_env_options( 

171 env_name=self.env_name, 

172 common_section=common_section, 

173 env_sections=env_sections, 

174 ) 

175 ) 

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

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

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

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

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

181 

182 @staticmethod 

183 def _patch_legacy_board_id( 

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

185 ) -> Optional[str]: 

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

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

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

189 options as are.""" 

190 

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

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

193 

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

195 if board_id is None: 

196 return 

197 

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

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

200 if board_id in boards: 

201 return 

202 

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

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

205 # -- board. 

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

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

208 section_options["board"] = canonical_id 

209 cwarning( 

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

211 "Please update apio.ini." 

212 ) 

213 return 

214 

215 @staticmethod 

216 def _validate_all_sections( 

217 apio_section: Optional[Dict[str, str]], 

218 common_section: Optional[Dict[str, str]], 

219 env_sections: Dict[str, Dict[str, str]], 

220 boards: Dict[str, Dict], 

221 ): 

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

223 

224 # -- Validate the common section. 

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

226 

227 # -- Validate the env sections. 

228 if not env_sections: 

229 cerror( 

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

231 "[env:name] section." 

232 ) 

233 sys.exit(1) 

234 

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

236 # -- Validate env name format. 

237 if not ENV_NAME_REGEX.match(env_name): 

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

239 cout(ENV_NAME_HINT, style=INFO) 

240 sys.exit(1) 

241 # -- Validate env section options. 

242 Project._validate_env_section( 

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

244 ) 

245 

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

247 # -- already validated. 

248 Project._validate_apio_section(apio_section, env_sections) 

249 

250 @staticmethod 

251 def _validate_apio_section( 

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

253 ): 

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

255 validated.""" 

256 

257 # -- Look for unknown options. 

258 for option in apio_section: 

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

260 cerror( 

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

262 ) 

263 sys.exit(1) 

264 

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

266 # -- and the name exists. 

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

268 if default_env_name: 

269 # -- Validate env name format. 

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

271 cerror( 

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

273 "in apio.ini." 

274 ) 

275 cout(ENV_NAME_HINT, style=INFO) 

276 sys.exit(1) 

277 # -- Make sure the env exists. 

278 if default_env_name not in env_sections: 

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

280 cout( 

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

282 "in apio.ini", 

283 style=INFO, 

284 ) 

285 sys.exit(1) 

286 

287 @staticmethod 

288 def _validate_env_section( 

289 section_title: str, 

290 section_options: Dict[str, str], 

291 boards: Dict[str, Dict], 

292 ): 

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

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

295 

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

297 for option in section_options: 

298 if option not in ENV_OPTIONS_SPEC: 

299 cerror( 

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

301 "section of apio.ini." 

302 ) 

303 sys.exit(1) 

304 

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

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

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

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

309 sys.exit(1) 

310 

311 @staticmethod 

312 def _determine_default_env_name( 

313 apio_section: Dict[str, str], 

314 env_sections: Dict[str, Dict[str, str]], 

315 env_arg: Optional[str], 

316 ) -> str: 

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

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

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

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

321 env_name = env_arg 

322 

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

324 # -- [apio] section. 

325 if env_name is None: 

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

327 

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

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

330 if env_name is None: 

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

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

333 

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

335 if env_name not in env_sections: 

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

337 cout( 

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

339 style=INFO, 

340 ) 

341 sys.exit(1) 

342 

343 # -- All done. 

344 return env_name 

345 

346 @staticmethod 

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

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

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

350 s = s.replace(k, v) 

351 return s 

352 

353 @staticmethod 

354 def _parse_env_options( 

355 env_name: str, 

356 common_section: Dict[str, str], 

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

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

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

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

361 and list options are returned as list of strings. 

362 """ 

363 

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

365 macros = { 

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

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

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

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

370 # -- The env name. 

371 "${ENV_NAME}": env_name, 

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

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

374 } 

375 

376 # -- Select the env section by name. 

377 env_section = env_sections[env_name] 

378 

379 # -- Create an empty result dict. 

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

381 # -- in apio.ini. 

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

383 

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

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

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

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

388 

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

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

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

392 

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

394 for option_spec in ENV_OPTIONS_SPEC.values(): 

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

396 cerror( 

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

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

399 ) 

400 sys.exit(1) 

401 

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

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

404 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(name) 

405 if option_spec.is_list: 

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

407 # -- Select the non empty items. 

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

409 result[name] = list_val 

410 

411 return result 

412 

413 def get_str_option( 

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

415 ) -> Union[str, Any]: 

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

417 

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

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

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

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

422 

423 # -- Lookup with default 

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

425 

426 if value is None: 

427 return default 

428 

429 assert isinstance(value, str) 

430 return value 

431 

432 def get_list_option( 

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

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

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

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

437 must be in OPTIONS.""" 

438 

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

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

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

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

443 

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

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

446 

447 # -- If not found, return default 

448 if values_list is None: 

449 return default 

450 

451 # -- Return the list 

452 assert isinstance(values_list, list), values_list 

453 return values_list 

454 

455 

456def load_project_from_file( 

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

458) -> Project: 

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

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

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

462 call its validate() method.""" 

463 

464 # -- Construct the apio.ini path. 

465 file_path = project_dir / "apio.ini" 

466 

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

468 if not file_path.exists(): 

469 cerror( 

470 "Missing project file apio.ini.", 

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

472 ) 

473 sys.exit(1) 

474 

475 # -- Read and parse the file. 

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

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

478 # -- default. 

479 parser = configparser.ConfigParser(dict_type=OrderedDict) 

480 try: 

481 parser.read(file_path) 

482 except configparser.Error as e: 

483 cerror(e) 

484 sys.exit(1) 

485 

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

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

488 # -- no duplicates. 

489 sections_names = parser.sections() 

490 

491 apio_section = None 

492 common_section = None 

493 env_sections = {} 

494 

495 for section_name in sections_names: 

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

497 if section_name == "apio": 

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

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

500 sys.exit(1) 

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

502 continue 

503 

504 # -- Handle the [common] section. 

505 if section_name == "common": 

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

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

508 sys.exit(1) 

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

510 continue 

511 

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

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

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

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

516 cwarning( 

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

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

519 ) 

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

521 continue 

522 

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

524 tokes = section_name.split(":") 

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

526 env_name = tokes[1] 

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

528 continue 

529 

530 # -- Handle unknown section name. 

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

532 cout( 

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

534 style=INFO, 

535 ) 

536 sys.exit(1) 

537 

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

539 return Project( 

540 apio_section=apio_section or {}, 

541 common_section=common_section or {}, 

542 env_sections=env_sections, 

543 env_arg=env_arg, 

544 boards=boards, 

545 ) 

546 

547 

548def create_project_file( 

549 project_dir: Path, 

550 board_id: str, 

551 top_module: str, 

552): 

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

554 

555 # -- Construct the path 

556 ini_path = project_dir / "apio.ini" 

557 

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

559 if ini_path.exists(): 

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

561 sys.exit(1) 

562 

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

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

565 

566 section_name = "env:default" 

567 

568 config = ConfigObj(str(ini_path)) 

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

570 config[section_name] = {} 

571 config[section_name]["board"] = board_id 

572 config[section_name]["top-module"] = top_module 

573 

574 config.write() 

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