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
« 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."""
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
25DEFAULT_TOP_MODULE = "main"
27ENV_NAME_REGEX = re.compile(r"^[a-z][a-z0-9-]*$")
29ENV_NAME_HINT = (
30 "Env names should start with a-z, "
31 "followed by any number of a-z, 0-9, and '-'."
32)
34TOP_COMMENT = """\
35APIO project configuration file.
36For details see https://fpgawars.github.io/apio/docs/project-file
37"""
39# -- Apio options. These are the options that appear in the [apio] section.
40# -- They are not subject to inheritance and resolution.
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]
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."""
55 name: str
56 is_required: bool = False
57 is_list: bool = False
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}
107class Project:
108 """An instance of this class holds the information from the project's
109 apio.ini file.
110 """
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."""
124 # pylint: disable=too-many-arguments
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")
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 )
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 )
154 # -- Keep the names of all envs
155 self.env_names = list(env_sections.keys())
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 )
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")
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."""
186 # -- Validate the common section.
187 Project._validate_env_section("[common]", common_section, boards)
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 )
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 )
204 # -- Validate env section options.
205 Project._validate_env_section(
206 f"[env:{env_name}]", section_options, boards
207 )
209 # -- Validate the apio section. At this point the env_sections are
210 # -- already validated.
211 Project._validate_apio_section(apio_section, env_sections)
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."""
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 )
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 )
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]."""
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 )
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.")
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
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)
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]
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 )
298 # -- All done.
299 return env_name
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
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 """
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 }
331 # -- Select the env section by name.
332 env_section = env_sections[env_name]
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]] = {}
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)
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)
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 )
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
366 return result
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."""
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}"
376 # -- Lookup with default
377 value = self.env_options.get(option, None)
379 if value is None:
380 return default
382 assert isinstance(value, str)
383 return value
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."""
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}"
397 # -- Get the option values, it's is expected to be a list of str.
398 values_list = self.env_options.get(option, None)
400 # -- If not found, return default
401 if values_list is None:
402 return default
404 # -- Return the list
405 assert isinstance(values_list, list), values_list
406 return values_list
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."""
419 # -- Construct the apio.ini path.
420 file_path = project_dir / "apio.ini"
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 )
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)
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()
444 apio_section: dict[str, Any] = {}
445 common_section: dict[str, Any] = {}
446 env_sections: dict[str, dict[str, Any]] = {}
448 common_section_found: bool = False
449 env_sections_found: bool = False
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
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
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 )
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
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 )
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 )
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."""
507 # -- Construct the path
508 ini_path = project_dir / "apio.ini"
510 # -- Error if apio.ini already exists.
511 if ini_path.exists():
512 fatal_error("The file apio.ini already exists.")
514 # -- Construct and write the apio.ini file..
515 cout(f"Creating {ini_path} file ...")
517 section_name = "env:default"
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)