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

175 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 03:53 +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 re 

13from dataclasses import dataclass 

14import configparser 

15from collections import OrderedDict 

16from pathlib import Path 

17from typing import Any 

18from configobj import ConfigObj 

19from apio.common.debug_util import is_debug 

20from apio.common.apio_console import cout, fatal_error 

21from apio.common.apio_styles import SUCCESS, EMPH2 

22from apio.common.common_util import PROJECT_BUILD_PATH 

23from apio.common.proto.apio_definitions_pb2 import BoardDefinition 

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-extra-options": EnvOptionSpec( 

86 name="yosys-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 "verilator-extra-options": EnvOptionSpec( 

98 name="verilator-extra-options", 

99 is_list=True, 

100 ), 

101 "constraint-file": EnvOptionSpec( 

102 name="constraint-file", 

103 ), 

104} 

105 

106 

107class Project: 

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

109 apio.ini file. 

110 """ 

111 

112 def __init__( 

113 self, 

114 *, 

115 apio_section: dict[str, Any], 

116 common_section: dict[str, Any], 

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

118 env_arg: str | None, 

119 boards: dict[str, BoardDefinition], 

120 ): 

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

122 line arg, and boards resources.""" 

123 

124 # pylint: disable=too-many-arguments 

125 

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

127 cout() 

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

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

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

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

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

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

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

135 

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

137 if env_arg is not None: 

138 if not ENV_NAME_REGEX.match(env_arg): 

139 fatal_error( 

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

141 info=ENV_NAME_HINT, 

142 ) 

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, 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 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 _validate_all_sections( 

179 apio_section: dict[str, str], 

180 common_section: dict, 

181 env_sections: dict[str, dict[str, str]], 

182 boards: dict[str, BoardDefinition], 

183 ): 

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

185 

186 # -- Validate the common section. 

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

188 

189 # -- Validate the env sections. 

190 if not env_sections: 

191 fatal_error( 

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

193 + "[env:name] section." 

194 ) 

195 

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

197 # -- Validate env name format. 

198 if not ENV_NAME_REGEX.match(env_name): 

199 fatal_error( 

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

201 info=ENV_NAME_HINT, 

202 ) 

203 

204 # -- Validate env section options. 

205 Project._validate_env_section( 

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

207 ) 

208 

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

210 # -- already validated. 

211 Project._validate_apio_section(apio_section, env_sections) 

212 

213 @staticmethod 

214 def _validate_apio_section( 

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

216 ): 

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

218 validated.""" 

219 

220 # -- Look for unknown options. 

221 for option in apio_section: 

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

223 fatal_error( 

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

225 ) 

226 

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

228 # -- and the name exists. 

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

230 if default_env_name: 

231 # -- Validate env name format. 

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

233 fatal_error( 

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

235 + "in apio.ini.", 

236 info=ENV_NAME_HINT, 

237 ) 

238 # -- Make sure the env exists. 

239 if default_env_name not in env_sections: 

240 fatal_error( 

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

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

243 + "in apio.ini", 

244 ) 

245 

246 @staticmethod 

247 def _validate_env_section( 

248 section_title: str, 

249 section_options: dict[str, str], 

250 boards: dict[str, BoardDefinition], 

251 ): 

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

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

254 

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

256 for option in section_options: 

257 if option not in ENV_OPTIONS_SPEC: 

258 fatal_error( 

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

260 + "section of apio.ini." 

261 ) 

262 

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

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

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

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

267 

268 @staticmethod 

269 def _determine_default_env_name( 

270 apio_section: dict[str, str], 

271 env_sections: dict[str, dict[str, str]], 

272 env_arg: str | None, 

273 ) -> str: 

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

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

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

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

278 env_name = env_arg 

279 

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

281 # -- [apio] section. 

282 if env_name is None: 

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

284 

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

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

287 if env_name is None: 

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

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

290 

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

292 if env_name not in env_sections: 

293 fatal_error( 

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

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

296 ) 

297 

298 # -- All done. 

299 return env_name 

300 

301 @staticmethod 

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

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

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

305 s = s.replace(k, v) 

306 return s 

307 

308 @staticmethod 

309 def _parse_env_options( 

310 env_name: str, 

311 common_section: dict, 

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

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

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

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

316 and list options are returned as list of strings. 

317 """ 

318 

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

320 macros = { 

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

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

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

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

325 # -- The env name. 

326 "${ENV_NAME}": env_name, 

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

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

329 } 

330 

331 # -- Select the env section by name. 

332 env_section = env_sections[env_name] 

333 

334 # -- Create an empty result dict. 

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

336 # -- in apio.ini. 

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

338 

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

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

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

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

343 

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

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

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

347 

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

349 for option_spec in ENV_OPTIONS_SPEC.values(): 

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

351 fatal_error( 

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

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

354 ) 

355 

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

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

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

359 if list_option_spec and list_option_spec.is_list: 

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

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

362 # -- Select the non empty items. 

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

364 result[name] = list_val 

365 

366 return result 

367 

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

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

370 

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

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

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

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

375 

376 # -- Lookup with default 

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

378 

379 if value is None: 

380 return default 

381 

382 assert isinstance(value, str) 

383 return value 

384 

385 def get_list_option( 

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

387 ) -> list[str] | Any: 

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

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

390 must be in OPTIONS.""" 

391 

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

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

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

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

396 

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

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

399 

400 # -- If not found, return default 

401 if values_list is None: 

402 return default 

403 

404 # -- Return the list 

405 assert isinstance(values_list, list), values_list 

406 return values_list 

407 

408 

409def load_project_from_file( 

410 project_dir: Path, 

411 env_arg: str | None, 

412 boards: dict[str, BoardDefinition], 

413) -> Project: 

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

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

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

417 call its validate() method.""" 

418 

419 # -- Construct the apio.ini path. 

420 file_path = project_dir / "apio.ini" 

421 

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

423 if not file_path.exists(): 

424 fatal_error( 

425 "Missing project file apio.ini.", 

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

427 ) 

428 

429 # -- Read and parse the file. 

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

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

432 # -- default. 

433 parser = configparser.ConfigParser(dict_type=OrderedDict) 

434 try: 

435 parser.read(file_path) 

436 except configparser.Error as e: 

437 fatal_error(cause=e) 

438 

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

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

441 # -- no duplicates. 

442 sections_names = parser.sections() 

443 

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

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

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

447 

448 common_section_found: bool = False 

449 env_sections_found: bool = False 

450 

451 for section_name in sections_names: 

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

453 if section_name == "apio": 

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

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

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

457 continue 

458 

459 # -- Handle the [common] section. 

460 if section_name == "common": 

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

462 fatal_error( 

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

464 ) 

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

466 common_section_found = True 

467 continue 

468 

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

470 if section_name == "env": 

471 fatal_error( 

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

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

474 ) 

475 

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

477 tokes = section_name.split(":") 

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

479 env_name = tokes[1] 

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

481 continue 

482 

483 # -- Handle unknown section name. 

484 fatal_error( 

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

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

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

488 ) 

489 

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

491 return Project( 

492 apio_section=apio_section, 

493 common_section=common_section, 

494 env_sections=env_sections, 

495 env_arg=env_arg, 

496 boards=boards, 

497 ) 

498 

499 

500def create_project_file( 

501 project_dir: Path, 

502 board_id: str, 

503 top_module: str, 

504): 

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

506 

507 # -- Construct the path 

508 ini_path = project_dir / "apio.ini" 

509 

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

511 if ini_path.exists(): 

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

513 

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

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

516 

517 section_name = "env:default" 

518 

519 config = ConfigObj(str(ini_path)) 

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

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

522 config.write() 

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