Coverage for apio/managers/project.py: 88%
175 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
1# This file is part of the Apio project
2# (C) 2016-present FPGAwars
3# Project authors:
4# * Jesús Arroyo (Jesus89) (2016-2019)
5# * Juan Gonzalez (obijuan) (2019-2024)
6# * The Apio Team (2024-present)
7# License: GPLv2
9"""Utility functionality for apio click commands."""
11import re
12from dataclasses import dataclass
13import configparser
14from collections import OrderedDict
15from pathlib import Path
16from typing import Any
17from configobj import ConfigObj
18from apio.common.debug_util import is_debug
19from apio.common.apio_console import cout, fatal_error
20from apio.common.apio_styles import SUCCESS, EMPH2
21from apio.common.common_util import PROJECT_BUILD_PATH
22from apio.common.proto.stubs.apio_definitions_pb2 import BoardDefinition
24DEFAULT_TOP_MODULE = "main"
26ENV_NAME_REGEX = re.compile(r"^[a-z][a-z0-9-]*$")
28ENV_NAME_HINT = (
29 "Env names should start with a-z, "
30 "followed by any number of a-z, 0-9, and '-'."
31)
33TOP_COMMENT = """\
34APIO project configuration file.
35For details see https://fpgawars.github.io/apio/docs/project-file
36"""
38# -- Apio options. These are the options that appear in the [apio] section.
39# -- They are not subject to inheritance and resolution.
42APIO_OPTIONS = [
43 # -- Selecting the env to use if not overridden in command line. Otherwise
44 # -- the first env is the default.
45 "default-env",
46]
49@dataclass(frozen=True)
50class EnvOptionSpec:
51 """Specifies a single apio.ini env option which can appear in an
52 env section or the common section."""
54 name: str
55 is_required: bool = False
56 is_list: bool = False
59# -- Specification of the env options which can appear in env sections
60# -- or the common section of apio.ini.
61ENV_OPTIONS_SPEC = {
62 "board": EnvOptionSpec(
63 name="board",
64 is_required=True,
65 ),
66 "top-module": EnvOptionSpec(
67 name="top-module",
68 is_required=True,
69 ),
70 "default-testbench": EnvOptionSpec(
71 name="default-testbench",
72 ),
73 "defines": EnvOptionSpec(
74 name="defines",
75 is_list=True,
76 ),
77 "format-verible-options": EnvOptionSpec(
78 name="format-verible-options",
79 is_list=True,
80 ),
81 "programmer-cmd": EnvOptionSpec(
82 name="programmer-cmd",
83 ),
84 "yosys-extra-options": EnvOptionSpec(
85 name="yosys-extra-options",
86 is_list=True,
87 ),
88 "nextpnr-extra-options": EnvOptionSpec(
89 name="nextpnr-extra-options",
90 is_list=True,
91 ),
92 "gtkwave-extra-options": EnvOptionSpec(
93 name="gtkwave-extra-options",
94 is_list=True,
95 ),
96 "verilator-extra-options": EnvOptionSpec(
97 name="verilator-extra-options",
98 is_list=True,
99 ),
100 "constraint-file": EnvOptionSpec(
101 name="constraint-file",
102 ),
103}
106class Project:
107 """An instance of this class holds the information from the project's
108 apio.ini file.
109 """
111 def __init__(
112 self,
113 *,
114 apio_section: dict[str, Any],
115 common_section: dict[str, Any],
116 env_sections: dict[str, dict[str, Any]],
117 env_arg: str | None,
118 boards: dict[str, BoardDefinition],
119 ):
120 """Construct the project with information from apio.ini, command
121 line arg, and boards resources."""
123 # pylint: disable=too-many-arguments
125 if is_debug(1): 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 cout()
127 cout("Parsed [apio] section:", style=EMPH2)
128 cout(f" {apio_section}\n")
129 cout("Parsed [common] section:", style=EMPH2)
130 cout(f" {common_section}\n")
131 for env_name, section_options in env_sections.items():
132 cout(f"Parsed [env:{env_name}] section:", style=EMPH2)
133 cout(f"{section_options}\n")
135 # -- Validate the format of the env_arg value.
136 if env_arg is not None:
137 if not ENV_NAME_REGEX.match(env_arg):
138 fatal_error(
139 f"Invalid --env value '{env_arg}'.",
140 info=ENV_NAME_HINT,
141 )
143 # -- Validate the apio.ini sections. We prefer to perform as much
144 # -- validation as possible before we expand the env because the env
145 # -- expansion may hide some options.
146 Project._validate_all_sections(
147 apio_section=apio_section,
148 common_section=common_section,
149 env_sections=env_sections,
150 boards=boards,
151 )
153 # -- Keep the names of all envs
154 self.env_names = list(env_sections.keys())
156 # -- Determine the name of the active env.
157 self.env_name = Project._determine_default_env_name(
158 apio_section, env_sections, env_arg
159 )
161 # -- Expand and selected env options. This is also patches default
162 # -- values and validates the results.
163 self.env_options: dict[str, str | list[str]] = (
164 Project._parse_env_options(
165 env_name=self.env_name,
166 common_section=common_section,
167 env_sections=env_sections,
168 )
169 )
170 if is_debug(1): 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 cout("Selected env name:", style=EMPH2)
172 cout(f" {self.env_name}\n")
173 cout("Expanded env options:", style=EMPH2)
174 cout(f" {self.env_options}\n")
176 @staticmethod
177 def _validate_all_sections(
178 apio_section: dict[str, str],
179 common_section: dict,
180 env_sections: dict[str, dict[str, str]],
181 boards: dict[str, BoardDefinition],
182 ):
183 """Validate the parsed apio.ini sections."""
185 # -- Validate the common section.
186 Project._validate_env_section("[common]", common_section, boards)
188 # -- Validate the env sections.
189 if not env_sections:
190 fatal_error(
191 "Project file 'apio.ini' should have at least one "
192 + "[env:name] section."
193 )
195 for env_name, section_options in env_sections.items():
196 # -- Validate env name format.
197 if not ENV_NAME_REGEX.match(env_name):
198 fatal_error(
199 f"Invalid env name '{env_name}' in apio.ini.",
200 info=ENV_NAME_HINT,
201 )
203 # -- Validate env section options.
204 Project._validate_env_section(
205 f"[env:{env_name}]", section_options, boards
206 )
208 # -- Validate the apio section. At this point the env_sections are
209 # -- already validated.
210 Project._validate_apio_section(apio_section, env_sections)
212 @staticmethod
213 def _validate_apio_section(
214 apio_section: dict[str, str], env_sections: dict[str, dict[str, str]]
215 ):
216 """Validate the [apio] section. 'env_sections' are assumed to be
217 validated."""
219 # -- Look for unknown options.
220 for option in apio_section:
221 if option not in APIO_OPTIONS: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 fatal_error(
223 f"Unknown option '{option} in [apio] section of apio.ini'"
224 )
226 # -- If 'default-env' option exists, verify the env name is valid and
227 # -- and the name exists.
228 default_env_name = apio_section.get("default-env", None)
229 if default_env_name:
230 # -- Validate env name format.
231 if not ENV_NAME_REGEX.match(default_env_name): 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 fatal_error(
233 f"Invalid default env name '{default_env_name}' "
234 + "in apio.ini.",
235 info=ENV_NAME_HINT,
236 )
237 # -- Make sure the env exists.
238 if default_env_name not in env_sections:
239 fatal_error(
240 f"Env '{default_env_name}' not found in apio.ini.",
241 info=f"Expecting an env section '{default_env_name}' "
242 + "in apio.ini",
243 )
245 @staticmethod
246 def _validate_env_section(
247 section_title: str,
248 section_options: dict[str, str],
249 boards: dict[str, BoardDefinition],
250 ):
251 """Validate the options of a section that contains env options. This
252 includes the sections [env:*] and [common]."""
254 # -- Check that there are no unknown options.
255 for option in section_options:
256 if option not in ENV_OPTIONS_SPEC:
257 fatal_error(
258 f"Unknown option '{option}' in {section_title} "
259 + "section of apio.ini."
260 )
262 # -- If 'board' option exists, verify that the board exists.
263 board_id = section_options.get("board", None)
264 if board_id is not None and board_id not in boards:
265 fatal_error(f"Unknown board id '{board_id}' in apio.ini.")
267 @staticmethod
268 def _determine_default_env_name(
269 apio_section: dict[str, str],
270 env_sections: dict[str, dict[str, str]],
271 env_arg: str | None,
272 ) -> str:
273 """Determines the active env name. Sections are assumed to be
274 validated. 'env_arg' is the value of the optional command line --env
275 which allows the user to select the env."""
276 # -- Priority #1 (highest): User specified env name in the command.
277 env_name = env_arg
279 # -- Priority #2: The optional default-env option in the
280 # -- [apio] section.
281 if env_name is None:
282 env_name = apio_section.get("default-env", None)
284 # -- Priority #3 (lowest): Picking the first env defined in apio.ini.
285 # -- Note that the envs order is preserved in env_sections.
286 if env_name is None:
287 # -- The env sections preserve the order in apio.ini.
288 env_name = list(env_sections.keys())[0]
290 # -- Error if the env doesn't exist.
291 if env_name not in env_sections:
292 fatal_error(
293 f"Env '{env_name}' not found in apio.ini.",
294 info=f"Expecting an env section '[env:{env_name}] in apio.ini",
295 )
297 # -- All done.
298 return env_name
300 @staticmethod
301 def _expand_value(s: str, macros: dict[str, str]) -> str:
302 """Expand macros by replacing macros keys with macro values."""
303 for k, v in macros.items():
304 s = s.replace(k, v)
305 return s
307 @staticmethod
308 def _parse_env_options(
309 env_name: str,
310 common_section: dict,
311 env_sections: dict[str, dict[str, str | list[str]]],
312 ) -> dict[str, str | list[str]]:
313 """Expand the options of given env name. The given common and envs
314 sections are already validate. String options are returned as strings
315 and list options are returned as list of strings.
316 """
318 # -- Key/Value dict for macro expansion.
319 macros = {
320 # -- The ';' char. (de-conflicted from ; comment)
321 "${SEMICOLON}": ";",
322 # -- The '#' char. (de-conflicted from # comment)
323 "${HASH}": "#",
324 # -- The env name.
325 "${ENV_NAME}": env_name,
326 # -- The relative path to env build directory (linux / style)
327 "${ENV_BUILD}": (PROJECT_BUILD_PATH / env_name).as_posix(),
328 }
330 # -- Select the env section by name.
331 env_section = env_sections[env_name]
333 # -- Create an empty result dict.
334 # -- We will insert to it the relevant options by the oder they appear
335 # -- in apio.ini.
336 result: dict[str, str | list[str]] = {}
338 # -- Add common options that are not in env section
339 for name, val in common_section.items():
340 if name not in env_section: 340 ↛ 339line 340 didn't jump to line 339 because the condition on line 340 was always true
341 result[name] = Project._expand_value(val, macros)
343 # -- Add all the options from the env section.
344 for name, val in env_section.items():
345 result[name] = Project._expand_value(str(val), macros)
347 # -- check that all the required options exist.
348 for option_spec in ENV_OPTIONS_SPEC.values():
349 if option_spec.is_required and option_spec.name not in result:
350 fatal_error(
351 f"Missing required option '{option_spec.name}' "
352 + f"for env '{env_name}'."
353 )
355 # -- Convert the list options from strings to list.
356 for name, str_val in result.items():
357 list_option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(name)
358 if list_option_spec and list_option_spec.is_list:
359 if isinstance(str_val, str): 359 ↛ 356line 359 didn't jump to line 356 because the condition on line 359 was always true
360 list_val = str_val.split("\n")
361 # -- Select the non empty items.
362 list_val = [x for x in list_val if x]
363 result[name] = list_val
365 return result
367 def get_str_option(self, option: str, default: Any = None) -> str | Any:
368 """Lookup an env option value by name. Returns default if not found."""
370 # -- If this fails, this is a programming error.
371 option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(option, None)
372 assert option_spec, f"Invalid env option: [{option}]"
373 assert not option_spec.is_list, f"Not a simple str option: {option}"
375 # -- Lookup with default
376 value = self.env_options.get(option, None)
378 if value is None:
379 return default
381 assert isinstance(value, str)
382 return value
384 def get_list_option(
385 self, option: str, default: Any = None
386 ) -> list[str] | Any:
387 """Lookup an env option value that has a line list format. Returns
388 the list of non empty lines or default if no value. Option
389 must be in OPTIONS."""
391 # -- If this fails, this is a programming error.
392 option_spec: EnvOptionSpec | None = ENV_OPTIONS_SPEC.get(option, None)
393 assert option_spec, f"Invalid env option: [{option}]"
394 assert option_spec.is_list, f"Not a list option: {option}"
396 # -- Get the option values, it's is expected to be a list of str.
397 values_list = self.env_options.get(option, None)
399 # -- If not found, return default
400 if values_list is None:
401 return default
403 # -- Return the list
404 assert isinstance(values_list, list), values_list
405 return values_list
408def load_project_from_file(
409 project_dir: Path,
410 env_arg: str | None,
411 boards: dict[str, BoardDefinition],
412) -> Project:
413 """Read project file from given project dir. Returns None if file
414 does not exists. Exits on any error. Otherwise creates adn
415 return an Project with the values. To validate the project object
416 call its validate() method."""
418 # -- Construct the apio.ini path.
419 file_path = project_dir / "apio.ini"
421 # -- Currently, apio.ini is still optional so we just warn.
422 if not file_path.exists():
423 fatal_error(
424 "Missing project file apio.ini.",
425 f"Expected a file at '{file_path.absolute()}'",
426 )
428 # -- Read and parse the file.
429 # -- By using OrderedDict we cause the parser to preserve the order of
430 # -- options in a section. The order of sections is already preserved by
431 # -- default.
432 parser = configparser.ConfigParser(dict_type=OrderedDict)
433 try:
434 parser.read(file_path)
435 except configparser.Error as e:
436 fatal_error(cause=e)
438 # -- Iterate and collect the sections in the order they appear in
439 # -- the apio.ini file. Section names are guaranteed to be unique with
440 # -- no duplicates.
441 sections_names = parser.sections()
443 apio_section: dict[str, Any] = {}
444 common_section: dict[str, Any] = {}
445 env_sections: dict[str, dict[str, Any]] = {}
447 common_section_found: bool = False
448 env_sections_found: bool = False
450 for section_name in sections_names:
451 # -- Handle the [apio[ section.]]
452 if section_name == "apio":
453 if common_section_found or env_sections_found: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 fatal_error("The [apio] section must be the first section.")
455 apio_section = dict(parser.items(section_name))
456 continue
458 # -- Handle the [common] section.
459 if section_name == "common":
460 if env_sections_found: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 fatal_error(
462 "The [common] section must be before [env:] sections."
463 )
464 common_section = dict(parser.items(section_name))
465 common_section_found = True
466 continue
468 # -- A bare [env] section is no longer accepted.
469 if section_name == "env":
470 fatal_error(
471 "Invalid section name 'env' in apio.ini.",
472 info="Rename it to [env:default].",
473 )
475 # -- Handle the [env:env-name] sections.
476 tokes = section_name.split(":")
477 if len(tokes) == 2 and tokes[0] == "env": 477 ↛ 483line 477 didn't jump to line 483 because the condition on line 477 was always true
478 env_name = tokes[1]
479 env_sections[env_name] = dict(parser.items(section_name))
480 continue
482 # -- Handle unknown section name.
483 fatal_error(
484 f"Invalid section name '{section_name}' in apio.ini.",
485 info="The valid section names are [apio], [common], "
486 + "and [env:env-name]",
487 )
489 # -- Construct the Project object. Its constructor validates the options.
490 return Project(
491 apio_section=apio_section,
492 common_section=common_section,
493 env_sections=env_sections,
494 env_arg=env_arg,
495 boards=boards,
496 )
499def create_project_file(
500 project_dir: Path,
501 board_id: str,
502 top_module: str,
503):
504 """Creates a new basic apio project file. Exits on any error."""
506 # -- Construct the path
507 ini_path = project_dir / "apio.ini"
509 # -- Error if apio.ini already exists.
510 if ini_path.exists():
511 fatal_error("The file apio.ini already exists.")
513 # -- Construct and write the apio.ini file..
514 cout(f"Creating {ini_path} file ...")
516 section_name = "env:default"
518 config = ConfigObj(str(ini_path))
519 config.initial_comment = TOP_COMMENT.split("\n")
520 config[section_name] = {"board": board_id, "top-module": top_module}
521 config.write()
522 cout(f"The file '{ini_path}' was created successfully.", style=SUCCESS)