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
« 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."""
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
26DEFAULT_TOP_MODULE = "main"
28ENV_NAME_REGEX = re.compile(r"^[a-z][a-z0-9-]*$")
30ENV_NAME_HINT = (
31 "Env names should start with a-z, "
32 "followed by any number of a-z, 0-9, and '-'."
33)
35TOP_COMMENT = """\
36APIO project configuration file.
37For details see https://fpgawars.github.io/apio/docs/project-file
38"""
40# -- Apio options. These are the options that appear in the [apio] section.
41# -- They are not subject to inheritance and resolution.
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]
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."""
56 name: str
57 is_required: bool = False
58 is_list: bool = False
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}
108class Project:
109 """An instance of this class holds the information from the project's
110 apio.ini file.
111 """
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."""
125 # pylint: disable=too-many-arguments
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")
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)
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)
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 )
161 # -- Keep the names of all envs
162 self.env_names = list(env_sections.keys())
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 )
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")
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."""
193 # -- Get the value of the "board" option.
194 board_id = section_options.get("board", None)
196 # -- Nothing to do if no "board" option.
197 if board_id is None:
198 return
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
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
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."""
226 # -- Validate the common section.
227 Project._validate_env_section("[common]", common_section, boards)
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)
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 )
248 # -- Validate the apio section. At this point the env_sections are
249 # -- already validated.
250 Project._validate_apio_section(apio_section, env_sections)
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."""
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)
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)
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]."""
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)
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)
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
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)
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]
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)
345 # -- All done.
346 return env_name
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
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 """
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 }
378 # -- Select the env section by name.
379 env_section = env_sections[env_name]
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]]] = {}
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)
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)
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)
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
414 return result
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."""
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}"
426 # -- Lookup with default
427 value = self.env_options.get(option, None)
429 if value is None:
430 return default
432 assert isinstance(value, str)
433 return value
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."""
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}"
447 # -- Get the option values, it's is expected to be a list of str.
448 values_list = self.env_options.get(option, None)
450 # -- If not found, return default
451 if values_list is None:
452 return default
454 # -- Return the list
455 assert isinstance(values_list, list), values_list
456 return values_list
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."""
467 # -- Construct the apio.ini path.
468 file_path = project_dir / "apio.ini"
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)
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)
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()
494 apio_section = None
495 common_section = None
496 env_sections = {}
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
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
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
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
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)
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 )
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."""
560 # -- Construct the path
561 ini_path = project_dir / "apio.ini"
563 # -- Error if apio.ini already exists.
564 if ini_path.exists():
565 cerror("The file apio.ini already exists.")
566 sys.exit(1)
568 # -- Construct and write the apio.ini file..
569 cout(f"Creating {ini_path} file ...")
571 section_name = "env:default"
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)