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

205 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-08 02:47 +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 

23 

24 

25DEFAULT_TOP_MODULE = "main" 

26 

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

28 

29ENV_NAME_HINT = ( 

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

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

32) 

33 

34TOP_COMMENT = """\ 

35APIO project configuration file. 

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

37""" 

38 

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

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

41 

42 

43APIO_OPTIONS = [ 

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

45 # -- the first env is the default. 

46 "default-env", 

47] 

48 

49 

50@dataclass(frozen=True) 

51class EnvOptionSpec: 

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

53 env section or the common section.""" 

54 

55 name: str 

56 is_required: bool = False 

57 is_list: bool = False 

58 

59 

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

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

62ENV_OPTIONS_SPEC = { 

63 "board": EnvOptionSpec( 

64 name="board", 

65 is_required=True, 

66 ), 

67 "top-module": EnvOptionSpec( 

68 name="top-module", 

69 is_required=True, 

70 ), 

71 "default-testbench": EnvOptionSpec( 

72 name="default-testbench", 

73 ), 

74 "defines": EnvOptionSpec( 

75 name="defines", 

76 is_list=True, 

77 ), 

78 "format-verible-options": EnvOptionSpec( 

79 name="format-verible-options", 

80 is_list=True, 

81 ), 

82 "programmer-cmd": EnvOptionSpec( 

83 name="programmer-cmd", 

84 ), 

85 "yosys-synth-extra-options": EnvOptionSpec( 

86 name="yosys-synth-extra-options", 

87 is_list=True, 

88 ), 

89 "nextpnr-extra-options": EnvOptionSpec( 

90 name="nextpnr-extra-options", 

91 is_list=True, 

92 ), 

93 "gtkwave-extra-options": EnvOptionSpec( 

94 name="gtkwave-extra-options", 

95 is_list=True, 

96 ), 

97 "constraint-file": EnvOptionSpec( 

98 name="constraint-file", 

99 ), 

100} 

101 

102 

103class Project: 

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

105 apio.ini file. 

106 """ 

107 

108 def __init__( 

109 self, 

110 *, 

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

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

113 env_sections: Dict[str, Dict[str, str]], 

114 env_arg: Optional[str], 

115 boards: Dict[str, Dict], 

116 ): 

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

118 line arg, and boards resources.""" 

119 

120 # pylint: disable=too-many-arguments 

121 

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

123 cout() 

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

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

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

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

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

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

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

131 

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

133 if env_arg is not None: 

134 if not ENV_NAME_REGEX.match(env_arg): 

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

136 cout(ENV_NAME_HINT, style=INFO) 

137 sys.exit(1) 

138 

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

140 Project._patch_legacy_board_id(common_section, boards) 

141 for section_options in env_sections.values(): 

142 Project._patch_legacy_board_id(section_options, boards) 

143 

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

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

146 # -- expansion may hide some options. 

147 Project._validate_all_sections( 

148 apio_section=apio_section, 

149 common_section=common_section, 

150 env_sections=env_sections, 

151 boards=boards, 

152 ) 

153 

154 # -- Keep the names of all envs 

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

156 

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

158 self.env_name = Project._determine_default_env_name( 

159 apio_section, env_sections, env_arg 

160 ) 

161 

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

163 # -- values and validates the results. 

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

165 Project._parse_env_options( 

166 env_name=self.env_name, 

167 common_section=common_section, 

168 env_sections=env_sections, 

169 ) 

170 ) 

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

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

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

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

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

176 

177 @staticmethod 

178 def _patch_legacy_board_id( 

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

180 ) -> Optional[str]: 

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

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

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

184 options as are.""" 

185 

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

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

188 

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

190 if board_id is None: 

191 return 

192 

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

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

195 if board_id in boards: 

196 return 

197 

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

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

200 # -- board. 

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

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

203 section_options["board"] = canonical_id 

204 cwarning( 

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

206 "Please update apio.ini." 

207 ) 

208 return 

209 

210 @staticmethod 

211 def _validate_all_sections( 

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

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

214 env_sections: Dict[str, Dict[str, str]], 

215 boards: Dict[str, Dict], 

216 ): 

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

218 

219 # -- Validate the common section. 

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

221 

222 # -- Validate the env sections. 

223 if not env_sections: 

224 cerror( 

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

226 "[env:name] section." 

227 ) 

228 sys.exit(1) 

229 

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

231 # -- Validate env name format. 

232 if not ENV_NAME_REGEX.match(env_name): 

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

234 cout(ENV_NAME_HINT, style=INFO) 

235 sys.exit(1) 

236 # -- Validate env section options. 

237 Project._validate_env_section( 

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

239 ) 

240 

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

242 # -- already validated. 

243 Project._validate_apio_section(apio_section, env_sections) 

244 

245 @staticmethod 

246 def _validate_apio_section( 

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

248 ): 

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

250 validated.""" 

251 

252 # -- Look for unknown options. 

253 for option in apio_section: 

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

255 cerror( 

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

257 ) 

258 sys.exit(1) 

259 

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

261 # -- and the name exists. 

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

263 if default_env_name: 

264 # -- Validate env name format. 

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

266 cerror( 

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

268 "in apio.ini." 

269 ) 

270 cout(ENV_NAME_HINT, style=INFO) 

271 sys.exit(1) 

272 # -- Make sure the env exists. 

273 if default_env_name not in env_sections: 

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

275 cout( 

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

277 "in apio.ini", 

278 style=INFO, 

279 ) 

280 sys.exit(1) 

281 

282 @staticmethod 

283 def _validate_env_section( 

284 section_title: str, 

285 section_options: Dict[str, str], 

286 boards: Dict[str, Dict], 

287 ): 

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

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

290 

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

292 for option in section_options: 

293 if option not in ENV_OPTIONS_SPEC: 

294 cerror( 

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

296 "section of apio.ini." 

297 ) 

298 sys.exit(1) 

299 

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

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

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

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

304 sys.exit(1) 

305 

306 @staticmethod 

307 def _determine_default_env_name( 

308 apio_section: Dict[str, str], 

309 env_sections: Dict[str, Dict[str, str]], 

310 env_arg: Optional[str], 

311 ) -> str: 

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

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

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

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

316 env_name = env_arg 

317 

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

319 # -- [apio] section. 

320 if env_name is None: 

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

322 

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

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

325 if env_name is None: 

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

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

328 

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

330 if env_name not in env_sections: 

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

332 cout( 

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

334 style=INFO, 

335 ) 

336 sys.exit(1) 

337 

338 # -- All done. 

339 return env_name 

340 

341 @staticmethod 

342 def _parse_env_options( 

343 env_name: str, 

344 common_section: Dict[str, str], 

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

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

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

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

349 and list options are returned as list of strings. 

350 """ 

351 

352 # -- Select the env section by name. 

353 env_section = env_sections[env_name] 

354 

355 # -- Create an empty result dict. 

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

357 # -- in apio.ini. 

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

359 

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

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

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

363 result[name] = val 

364 

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

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

367 result[name] = val 

368 

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

370 for option_spec in ENV_OPTIONS_SPEC.values(): 

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

372 cerror( 

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

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

375 ) 

376 sys.exit(1) 

377 

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

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

380 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(name) 

381 if option_spec.is_list: 

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

383 # -- Select the non empty items. 

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

385 result[name] = list_val 

386 

387 return result 

388 

389 def get_str_option( 

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

391 ) -> Union[str, Any]: 

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

393 

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

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

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

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

398 

399 # -- Lookup with default 

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

401 

402 if value is None: 

403 return default 

404 

405 assert isinstance(value, str) 

406 return value 

407 

408 def get_list_option( 

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

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

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

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

413 must be in OPTIONS.""" 

414 

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

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

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

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

419 

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

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

422 

423 # -- If not found, return default 

424 if values_list is None: 

425 return default 

426 

427 # -- Return the list 

428 assert isinstance(values_list, list), values_list 

429 return values_list 

430 

431 

432def load_project_from_file( 

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

434) -> Project: 

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

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

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

438 call its validate() method.""" 

439 

440 # -- Construct the apio.ini path. 

441 file_path = project_dir / "apio.ini" 

442 

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

444 if not file_path.exists(): 

445 cerror( 

446 "Missing project file apio.ini.", 

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

448 ) 

449 sys.exit(1) 

450 

451 # -- Read and parse the file. 

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

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

454 # -- default. 

455 parser = configparser.ConfigParser(dict_type=OrderedDict) 

456 try: 

457 parser.read(file_path) 

458 except configparser.Error as e: 

459 cerror(e) 

460 sys.exit(1) 

461 

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

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

464 # -- no duplicates. 

465 sections_names = parser.sections() 

466 

467 apio_section = None 

468 common_section = None 

469 env_sections = {} 

470 

471 for section_name in sections_names: 

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

473 if section_name == "apio": 

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

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

476 sys.exit(1) 

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

478 continue 

479 

480 # -- Handle the [common] section. 

481 if section_name == "common": 

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

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

484 sys.exit(1) 

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

486 continue 

487 

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

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

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

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

492 cwarning( 

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

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

495 ) 

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

497 continue 

498 

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

500 tokes = section_name.split(":") 

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

502 env_name = tokes[1] 

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

504 continue 

505 

506 # -- Handle unknown section name. 

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

508 cout( 

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

510 style=INFO, 

511 ) 

512 sys.exit(1) 

513 

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

515 return Project( 

516 apio_section=apio_section or {}, 

517 common_section=common_section or {}, 

518 env_sections=env_sections, 

519 env_arg=env_arg, 

520 boards=boards, 

521 ) 

522 

523 

524def create_project_file( 

525 project_dir: Path, 

526 board_id: str, 

527 top_module: str, 

528): 

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

530 

531 # -- Construct the path 

532 ini_path = project_dir / "apio.ini" 

533 

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

535 if ini_path.exists(): 

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

537 sys.exit(1) 

538 

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

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

541 

542 section_name = "env:default" 

543 

544 config = ConfigObj(str(ini_path)) 

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

546 config[section_name] = {} 

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

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

549 

550 config.write() 

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