Coverage for apio / managers / project.py: 88%
212 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 02:31 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 02:31 +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: Optional[Dict[str, str]],
117 common_section: Optional[Dict[str, str]],
118 env_sections: Dict[str, Dict[str, str]],
119 env_arg: Optional[str],
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(common_section, boards)
146 for section_options in env_sections.values():
147 Project._patch_legacy_board_id(section_options, boards)
149 # -- Validate the apio.ini sections. We prefer to perform as much
150 # -- validation as possible before we expand the env because the env
151 # -- expansion may hide some options.
152 Project._validate_all_sections(
153 apio_section=apio_section,
154 common_section=common_section,
155 env_sections=env_sections,
156 boards=boards,
157 )
159 # -- Keep the names of all envs
160 self.env_names = list(env_sections.keys())
162 # -- Determine the name of the active env.
163 self.env_name = Project._determine_default_env_name(
164 apio_section, env_sections, env_arg
165 )
167 # -- Expand and selected env options. This is also patches default
168 # -- values and validates the results.
169 self.env_options: Dict[str, Union[str, List[str]]] = (
170 Project._parse_env_options(
171 env_name=self.env_name,
172 common_section=common_section,
173 env_sections=env_sections,
174 )
175 )
176 if util.is_debug(1): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 cout("Selected env name:", style=EMPH2)
178 cout(f" {self.env_name}\n")
179 cout("Expanded env options:", style=EMPH2)
180 cout(f" {self.env_options}\n")
182 @staticmethod
183 def _patch_legacy_board_id(
184 section_options: Dict[str, Dict[str, str]], boards: Dict[str, Dict]
185 ) -> Optional[str]:
186 """Temporary patching of old board ids to new in an env or common
187 section. If there is a "board" option with a legacy board id,
188 then change it to the board's canonical name. Otherwise, leave the
189 options as are."""
191 # -- Get the value of the "board" option.
192 board_id = section_options.get("board", None)
194 # -- Nothing to do if no "board" option.
195 if board_id is None:
196 return
198 # -- Nothing to do if board_id is in the boards dict. It's
199 # -- a good (new style) board id.
200 if board_id in boards:
201 return
203 # -- Iterate the boards and if board_id matches the legacy name of
204 # -- a board, change the "board" option to the canonical name of that
205 # -- board.
206 for canonical_id, board_info in boards.items():
207 if board_id == board_info.get("legacy-name", None):
208 section_options["board"] = canonical_id
209 cwarning(
210 f"'Board {board_id}' was renamed to '{canonical_id}'. "
211 "Please update apio.ini."
212 )
213 return
215 @staticmethod
216 def _validate_all_sections(
217 apio_section: Optional[Dict[str, str]],
218 common_section: Optional[Dict[str, str]],
219 env_sections: Dict[str, Dict[str, str]],
220 boards: Dict[str, Dict],
221 ):
222 """Validate the parsed apio.ini sections."""
224 # -- Validate the common section.
225 Project._validate_env_section("[common]", common_section, boards)
227 # -- Validate the env sections.
228 if not env_sections:
229 cerror(
230 "Project file 'apio.ini' should have at least one "
231 "[env:name] section."
232 )
233 sys.exit(1)
235 for env_name, section_options in env_sections.items():
236 # -- Validate env name format.
237 if not ENV_NAME_REGEX.match(env_name):
238 cerror(f"Invalid env name '{env_name}' in apio.ini.")
239 cout(ENV_NAME_HINT, style=INFO)
240 sys.exit(1)
241 # -- Validate env section options.
242 Project._validate_env_section(
243 f"[env:{env_name}]", section_options, boards
244 )
246 # -- Validate the apio section. At this point the env_sections are
247 # -- already validated.
248 Project._validate_apio_section(apio_section, env_sections)
250 @staticmethod
251 def _validate_apio_section(
252 apio_section: Dict[str, str], env_sections: Dict[str, Dict[str, str]]
253 ):
254 """Validate the [apio] section. 'env_sections' are assumed to be
255 validated."""
257 # -- Look for unknown options.
258 for option in apio_section:
259 if option not in APIO_OPTIONS: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 cerror(
261 f"Unknown option '{option} in [apio] section of apio.ini'"
262 )
263 sys.exit(1)
265 # -- If 'default-env' option exists, verify the env name is valid and
266 # -- and the name exists.
267 default_env_name = apio_section.get("default-env", None)
268 if default_env_name:
269 # -- Validate env name format.
270 if not ENV_NAME_REGEX.match(default_env_name): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 cerror(
272 f"Invalid default env name '{default_env_name}' "
273 "in apio.ini."
274 )
275 cout(ENV_NAME_HINT, style=INFO)
276 sys.exit(1)
277 # -- Make sure the env exists.
278 if default_env_name not in env_sections:
279 cerror(f"Env '{default_env_name}' not found in apio.ini.")
280 cout(
281 f"Expecting an env section '{default_env_name}' "
282 "in apio.ini",
283 style=INFO,
284 )
285 sys.exit(1)
287 @staticmethod
288 def _validate_env_section(
289 section_title: str,
290 section_options: Dict[str, str],
291 boards: Dict[str, Dict],
292 ):
293 """Validate the options of a section that contains env options. This
294 includes the sections [env:*] and [common]."""
296 # -- Check that there are no unknown options.
297 for option in section_options:
298 if option not in ENV_OPTIONS_SPEC:
299 cerror(
300 f"Unknown option '{option}' in {section_title} "
301 "section of apio.ini."
302 )
303 sys.exit(1)
305 # -- If 'board' option exists, verify that the board exists.
306 board_id = section_options.get("board", None)
307 if board_id is not None and board_id not in boards:
308 cerror(f"Unknown board id '{board_id}' in apio.ini.")
309 sys.exit(1)
311 @staticmethod
312 def _determine_default_env_name(
313 apio_section: Dict[str, str],
314 env_sections: Dict[str, Dict[str, str]],
315 env_arg: Optional[str],
316 ) -> str:
317 """Determines the active env name. Sections are assumed to be
318 validated. 'env_arg' is the value of the optional command line --env
319 which allows the user to select the env."""
320 # -- Priority #1 (highest): User specified env name in the command.
321 env_name = env_arg
323 # -- Priority #2: The optional default-env option in the
324 # -- [apio] section.
325 if env_name is None:
326 env_name = apio_section.get("default-env", None)
328 # -- Priority #3 (lowest): Picking the first env defined in apio.ini.
329 # -- Note that the envs order is preserved in env_sections.
330 if env_name is None:
331 # -- The env sections preserve the order in apio.ini.
332 env_name = list(env_sections.keys())[0]
334 # -- Error if the env doesn't exist.
335 if env_name not in env_sections:
336 cerror(f"Env '{env_name}' not found in apio.ini.")
337 cout(
338 f"Expecting an env section '[env:{env_name}] in apio.ini",
339 style=INFO,
340 )
341 sys.exit(1)
343 # -- All done.
344 return env_name
346 @staticmethod
347 def _expand_value(s: str, macros: Dict[str, str]) -> str:
348 """Expand macros by replacing macros keys with macro values."""
349 for k, v in macros.items():
350 s = s.replace(k, v)
351 return s
353 @staticmethod
354 def _parse_env_options(
355 env_name: str,
356 common_section: Dict[str, str],
357 env_sections: Dict[str, Dict[str, Union[str, List[str]]]],
358 ) -> Dict[str, Union[str, List[str]]]:
359 """Expand the options of given env name. The given common and envs
360 sections are already validate. String options are returned as strings
361 and list options are returned as list of strings.
362 """
364 # -- Key/Value dict for macro expansion.
365 macros = {
366 # -- The ';' char. (de-conflicted from ; comment)
367 "${SEMICOLON}": ";",
368 # -- The '#' char. (de-conflicted from # comment)
369 "${HASH}": "#",
370 # -- The env name.
371 "${ENV_NAME}": env_name,
372 # -- The relative path to env build directory (linux / style)
373 "${ENV_BUILD}": (PROJECT_BUILD_PATH / env_name).as_posix(),
374 }
376 # -- Select the env section by name.
377 env_section = env_sections[env_name]
379 # -- Create an empty result dict.
380 # -- We will insert to it the relevant options by the oder they appear
381 # -- in apio.ini.
382 result: Dict[str, Union[str, List[str]]] = {}
384 # -- Add common options that are not in env section
385 for name, val in common_section.items():
386 if name not in env_section: 386 ↛ 385line 386 didn't jump to line 385 because the condition on line 386 was always true
387 result[name] = Project._expand_value(val, macros)
389 # -- Add all the options from the env section.
390 for name, val in env_section.items():
391 result[name] = Project._expand_value(val, macros)
393 # -- check that all the required options exist.
394 for option_spec in ENV_OPTIONS_SPEC.values():
395 if option_spec.is_required and option_spec.name not in result:
396 cerror(
397 f"Missing required option '{option_spec.name}' "
398 f"for env '{env_name}'."
399 )
400 sys.exit(1)
402 # -- Convert the list options from strings to list.
403 for name, str_val in result.items():
404 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(name)
405 if option_spec.is_list:
406 list_val = str_val.split("\n")
407 # -- Select the non empty items.
408 list_val = [x for x in list_val if x]
409 result[name] = list_val
411 return result
413 def get_str_option(
414 self, option: str, default: Any = None
415 ) -> Union[str, Any]:
416 """Lookup an env option value by name. Returns default if not found."""
418 # -- If this fails, this is a programming error.
419 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(option, None)
420 assert option_spec, f"Invalid env option: [{option}]"
421 assert not option_spec.is_list, f"Not a simple str option: {option}"
423 # -- Lookup with default
424 value = self.env_options.get(option, None)
426 if value is None:
427 return default
429 assert isinstance(value, str)
430 return value
432 def get_list_option(
433 self, option: str, default: Any = None
434 ) -> Union[List[str], Any]:
435 """Lookup an env option value that has a line list format. Returns
436 the list of non empty lines or default if no value. Option
437 must be in OPTIONS."""
439 # -- If this fails, this is a programming error.
440 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(option, None)
441 assert option_spec, f"Invalid env option: [{option}]"
442 assert option_spec.is_list, f"Not a list option: {option}"
444 # -- Get the option values, it's is expected to be a list of str.
445 values_list = self.env_options.get(option, None)
447 # -- If not found, return default
448 if values_list is None:
449 return default
451 # -- Return the list
452 assert isinstance(values_list, list), values_list
453 return values_list
456def load_project_from_file(
457 project_dir: Path, env_arg: Optional[str], boards: Dict[str, Dict]
458) -> Project:
459 """Read project file from given project dir. Returns None if file
460 does not exists. Exits on any error. Otherwise creates adn
461 return an Project with the values. To validate the project object
462 call its validate() method."""
464 # -- Construct the apio.ini path.
465 file_path = project_dir / "apio.ini"
467 # -- Currently, apio.ini is still optional so we just warn.
468 if not file_path.exists():
469 cerror(
470 "Missing project file apio.ini.",
471 f"Expected a file at '{file_path.absolute()}'",
472 )
473 sys.exit(1)
475 # -- Read and parse the file.
476 # -- By using OrderedDict we cause the parser to preserve the order of
477 # -- options in a section. The order of sections is already preserved by
478 # -- default.
479 parser = configparser.ConfigParser(dict_type=OrderedDict)
480 try:
481 parser.read(file_path)
482 except configparser.Error as e:
483 cerror(e)
484 sys.exit(1)
486 # -- Iterate and collect the sections in the order they appear in
487 # -- the apio.ini file. Section names are guaranteed to be unique with
488 # -- no duplicates.
489 sections_names = parser.sections()
491 apio_section = None
492 common_section = None
493 env_sections = {}
495 for section_name in sections_names:
496 # -- Handle the [apio[ section.]]
497 if section_name == "apio":
498 if common_section or env_sections: 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true
499 cerror("The [apio] section must be the first section.")
500 sys.exit(1)
501 apio_section = dict(parser.items(section_name))
502 continue
504 # -- Handle the [common] section.
505 if section_name == "common":
506 if env_sections: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true
507 cerror("The [common] section must be before [env:] sections.")
508 sys.exit(1)
509 common_section = dict(parser.items(section_name))
510 continue
512 # TODO: Remove this option after this is released.
513 # -- Handle the legacy [env] section.
514 if section_name == "env" and len(sections_names) == 1:
515 # env_sections["default"] = parser.items(section_name)
516 cwarning(
517 "Apio.ini has a legacy [env] section. "
518 "Please rename it to [env:default]."
519 )
520 env_sections["default"] = dict(parser.items(section_name))
521 continue
523 # -- Handle the [env:env-name] sections.
524 tokes = section_name.split(":")
525 if len(tokes) == 2 and tokes[0] == "env": 525 ↛ 531line 525 didn't jump to line 531 because the condition on line 525 was always true
526 env_name = tokes[1]
527 env_sections[env_name] = dict(parser.items(section_name))
528 continue
530 # -- Handle unknown section name.
531 cerror(f"Invalid section name '{section_name}' in apio.ini.")
532 cout(
533 "The valid section names are [apio], [common], and [env:env-name]",
534 style=INFO,
535 )
536 sys.exit(1)
538 # -- Construct the Project object. Its constructor validates the options.
539 return Project(
540 apio_section=apio_section or {},
541 common_section=common_section or {},
542 env_sections=env_sections,
543 env_arg=env_arg,
544 boards=boards,
545 )
548def create_project_file(
549 project_dir: Path,
550 board_id: str,
551 top_module: str,
552):
553 """Creates a new basic apio project file. Exits on any error."""
555 # -- Construct the path
556 ini_path = project_dir / "apio.ini"
558 # -- Error if apio.ini already exists.
559 if ini_path.exists():
560 cerror("The file apio.ini already exists.")
561 sys.exit(1)
563 # -- Construct and write the apio.ini file..
564 cout(f"Creating {ini_path} file ...")
566 section_name = "env:default"
568 config = ConfigObj(str(ini_path))
569 config.initial_comment = TOP_COMMENT.split("\n")
570 config[section_name] = {}
571 config[section_name]["board"] = board_id
572 config[section_name]["top-module"] = top_module
574 config.write()
575 cout(f"The file '{ini_path}' was created successfully.", style=SUCCESS)