Coverage for apio / managers / project.py: 87%
205 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 02:47 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 02:47 +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
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-synth-extra-options": EnvOptionSpec(
86 name="yosys-synth-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 "constraint-file": EnvOptionSpec(
98 name="constraint-file",
99 ),
100}
103class Project:
104 """An instance of this class holds the information from the project's
105 apio.ini file.
106 """
108 def __init__(
109 self,
110 *,
111 apio_section: Optional[Dict[str, str]],
112 common_section: Optional[Dict[str, str]],
113 env_sections: Dict[str, Dict[str, str]],
114 env_arg: Optional[str],
115 boards: Dict[str, Dict],
116 ):
117 """Construct the project with information from apio.ini, command
118 line arg, and boards resources."""
120 # pylint: disable=too-many-arguments
122 if util.is_debug(1): 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 cout()
124 cout("Parsed [apio] section:", style=EMPH2)
125 cout(f" {apio_section}\n")
126 cout("Parsed [common] section:", style=EMPH2)
127 cout(f" {common_section}\n")
128 for env_name, section_options in env_sections.items():
129 cout(f"Parsed [env:{env_name}] section:", style=EMPH2)
130 cout(f"{section_options}\n")
132 # -- Validate the format of the env_arg value.
133 if env_arg is not None:
134 if not ENV_NAME_REGEX.match(env_arg):
135 cerror(f"Invalid --env value '{env_arg}'.")
136 cout(ENV_NAME_HINT, style=INFO)
137 sys.exit(1)
139 # -- Patch legacy board ids in the common and env sections.
140 Project._patch_legacy_board_id(common_section, boards)
141 for section_options in env_sections.values():
142 Project._patch_legacy_board_id(section_options, boards)
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, Union[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 util.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 _patch_legacy_board_id(
179 section_options: Dict[str, Dict[str, str]], boards: Dict[str, Dict]
180 ) -> Optional[str]:
181 """Temporary patching of old board ids to new in an env or common
182 section. If there is a "board" option with a legacy board id,
183 then change it to the board's canonical name. Otherwise, leave the
184 options as are."""
186 # -- Get the value of the "board" option.
187 board_id = section_options.get("board", None)
189 # -- Nothing to do if no "board" option.
190 if board_id is None:
191 return
193 # -- Nothing to do if board_id is in the boards dict. It's
194 # -- a good (new style) board id.
195 if board_id in boards:
196 return
198 # -- Iterate the boards and if board_id matches the legacy name of
199 # -- a board, change the "board" option to the canonical name of that
200 # -- board.
201 for canonical_id, board_info in boards.items():
202 if board_id == board_info.get("legacy-name", None):
203 section_options["board"] = canonical_id
204 cwarning(
205 f"'Board {board_id}' was renamed to '{canonical_id}'. "
206 "Please update apio.ini."
207 )
208 return
210 @staticmethod
211 def _validate_all_sections(
212 apio_section: Optional[Dict[str, str]],
213 common_section: Optional[Dict[str, str]],
214 env_sections: Dict[str, Dict[str, str]],
215 boards: Dict[str, Dict],
216 ):
217 """Validate the parsed apio.ini sections."""
219 # -- Validate the common section.
220 Project._validate_env_section("[common]", common_section, boards)
222 # -- Validate the env sections.
223 if not env_sections:
224 cerror(
225 "Project file 'apio.ini' should have at least one "
226 "[env:name] section."
227 )
228 sys.exit(1)
230 for env_name, section_options in env_sections.items():
231 # -- Validate env name format.
232 if not ENV_NAME_REGEX.match(env_name):
233 cerror(f"Invalid env name '{env_name}' in apio.ini.")
234 cout(ENV_NAME_HINT, style=INFO)
235 sys.exit(1)
236 # -- Validate env section options.
237 Project._validate_env_section(
238 f"[env:{env_name}]", section_options, boards
239 )
241 # -- Validate the apio section. At this point the env_sections are
242 # -- already validated.
243 Project._validate_apio_section(apio_section, env_sections)
245 @staticmethod
246 def _validate_apio_section(
247 apio_section: Dict[str, str], env_sections: Dict[str, Dict[str, str]]
248 ):
249 """Validate the [apio] section. 'env_sections' are assumed to be
250 validated."""
252 # -- Look for unknown options.
253 for option in apio_section:
254 if option not in APIO_OPTIONS: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 cerror(
256 f"Unknown option '{option} in [apio] section of apio.ini'"
257 )
258 sys.exit(1)
260 # -- If 'default-env' option exists, verify the env name is valid and
261 # -- and the name exists.
262 default_env_name = apio_section.get("default-env", None)
263 if default_env_name:
264 # -- Validate env name format.
265 if not ENV_NAME_REGEX.match(default_env_name): 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 cerror(
267 f"Invalid default env name '{default_env_name}' "
268 "in apio.ini."
269 )
270 cout(ENV_NAME_HINT, style=INFO)
271 sys.exit(1)
272 # -- Make sure the env exists.
273 if default_env_name not in env_sections:
274 cerror(f"Env '{default_env_name}' not found in apio.ini.")
275 cout(
276 f"Expecting an env section '{default_env_name}' "
277 "in apio.ini",
278 style=INFO,
279 )
280 sys.exit(1)
282 @staticmethod
283 def _validate_env_section(
284 section_title: str,
285 section_options: Dict[str, str],
286 boards: Dict[str, Dict],
287 ):
288 """Validate the options of a section that contains env options. This
289 includes the sections [env:*] and [common]."""
291 # -- Check that there are no unknown options.
292 for option in section_options:
293 if option not in ENV_OPTIONS_SPEC:
294 cerror(
295 f"Unknown option '{option}' in {section_title} "
296 "section of apio.ini."
297 )
298 sys.exit(1)
300 # -- If 'board' option exists, verify that the board exists.
301 board_id = section_options.get("board", None)
302 if board_id is not None and board_id not in boards:
303 cerror(f"Unknown board id '{board_id}' in apio.ini.")
304 sys.exit(1)
306 @staticmethod
307 def _determine_default_env_name(
308 apio_section: Dict[str, str],
309 env_sections: Dict[str, Dict[str, str]],
310 env_arg: Optional[str],
311 ) -> str:
312 """Determines the active env name. Sections are assumed to be
313 validated. 'env_arg' is the value of the optional command line --env
314 which allows the user to select the env."""
315 # -- Priority #1 (highest): User specified env name in the command.
316 env_name = env_arg
318 # -- Priority #2: The optional default-env option in the
319 # -- [apio] section.
320 if env_name is None:
321 env_name = apio_section.get("default-env", None)
323 # -- Priority #3 (lowest): Picking the first env defined in apio.ini.
324 # -- Note that the envs order is preserved in env_sections.
325 if env_name is None:
326 # -- The env sections preserve the order in apio.ini.
327 env_name = list(env_sections.keys())[0]
329 # -- Error if the env doesn't exist.
330 if env_name not in env_sections:
331 cerror(f"Env '{env_name}' not found in apio.ini.")
332 cout(
333 f"Expecting an env section '[env:{env_name}] in apio.ini",
334 style=INFO,
335 )
336 sys.exit(1)
338 # -- All done.
339 return env_name
341 @staticmethod
342 def _parse_env_options(
343 env_name: str,
344 common_section: Dict[str, str],
345 env_sections: Dict[str, Dict[str, Union[str, List[str]]]],
346 ) -> Dict[str, Union[str, List[str]]]:
347 """Expand the options of given env name. The given common and envs
348 sections are already validate. String options are returned as strings
349 and list options are returned as list of strings.
350 """
352 # -- Select the env section by name.
353 env_section = env_sections[env_name]
355 # -- Create an empty result dict.
356 # -- We will insert to it the relevant options by the oder they appear
357 # -- in apio.ini.
358 result: Dict[str, Union[str, List[str]]] = {}
360 # -- Add common options that are not in env section
361 for name, val in common_section.items():
362 if name not in env_section: 362 ↛ 361line 362 didn't jump to line 361 because the condition on line 362 was always true
363 result[name] = val
365 # -- Add all the options from the env section.
366 for name, val in env_section.items():
367 result[name] = val
369 # -- check that all the required options exist.
370 for option_spec in ENV_OPTIONS_SPEC.values():
371 if option_spec.is_required and option_spec.name not in result:
372 cerror(
373 f"Missing required option '{option_spec.name}' "
374 f"for env '{env_name}'."
375 )
376 sys.exit(1)
378 # -- Convert the list options from strings to list.
379 for name, str_val in result.items():
380 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(name)
381 if option_spec.is_list:
382 list_val = str_val.split("\n")
383 # -- Select the non empty items.
384 list_val = [x for x in list_val if x]
385 result[name] = list_val
387 return result
389 def get_str_option(
390 self, option: str, default: Any = None
391 ) -> Union[str, Any]:
392 """Lookup an env option value by name. Returns default if not found."""
394 # -- If this fails, this is a programming error.
395 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(option, None)
396 assert option_spec, f"Invalid env option: [{option}]"
397 assert not option_spec.is_list, f"Not a simple str option: {option}"
399 # -- Lookup with default
400 value = self.env_options.get(option, None)
402 if value is None:
403 return default
405 assert isinstance(value, str)
406 return value
408 def get_list_option(
409 self, option: str, default: Any = None
410 ) -> Union[List[str], Any]:
411 """Lookup an env option value that has a line list format. Returns
412 the list of non empty lines or default if no value. Option
413 must be in OPTIONS."""
415 # -- If this fails, this is a programming error.
416 option_spec: EnvOptionSpec = ENV_OPTIONS_SPEC.get(option, None)
417 assert option_spec, f"Invalid env option: [{option}]"
418 assert option_spec.is_list, f"Not a list option: {option}"
420 # -- Get the option values, it's is expected to be a list of str.
421 values_list = self.env_options.get(option, None)
423 # -- If not found, return default
424 if values_list is None:
425 return default
427 # -- Return the list
428 assert isinstance(values_list, list), values_list
429 return values_list
432def load_project_from_file(
433 project_dir: Path, env_arg: Optional[str], boards: Dict[str, Dict]
434) -> Project:
435 """Read project file from given project dir. Returns None if file
436 does not exists. Exits on any error. Otherwise creates adn
437 return an Project with the values. To validate the project object
438 call its validate() method."""
440 # -- Construct the apio.ini path.
441 file_path = project_dir / "apio.ini"
443 # -- Currently, apio.ini is still optional so we just warn.
444 if not file_path.exists():
445 cerror(
446 "Missing project file apio.ini.",
447 f"Expected a file at '{file_path.absolute()}'",
448 )
449 sys.exit(1)
451 # -- Read and parse the file.
452 # -- By using OrderedDict we cause the parser to preserve the order of
453 # -- options in a section. The order of sections is already preserved by
454 # -- default.
455 parser = configparser.ConfigParser(dict_type=OrderedDict)
456 try:
457 parser.read(file_path)
458 except configparser.Error as e:
459 cerror(e)
460 sys.exit(1)
462 # -- Iterate and collect the sections in the order they appear in
463 # -- the apio.ini file. Section names are guaranteed to be unique with
464 # -- no duplicates.
465 sections_names = parser.sections()
467 apio_section = None
468 common_section = None
469 env_sections = {}
471 for section_name in sections_names:
472 # -- Handle the [apio[ section.]]
473 if section_name == "apio":
474 if common_section or env_sections: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 cerror("The [apio] section must be the first section.")
476 sys.exit(1)
477 apio_section = dict(parser.items(section_name))
478 continue
480 # -- Handle the [common] section.
481 if section_name == "common":
482 if env_sections: 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true
483 cerror("The [common] section must be before [env:] sections.")
484 sys.exit(1)
485 common_section = dict(parser.items(section_name))
486 continue
488 # TODO: Remove this option after this is released.
489 # -- Handle the legacy [env] section.
490 if section_name == "env" and len(sections_names) == 1:
491 # env_sections["default"] = parser.items(section_name)
492 cwarning(
493 "Apio.ini has a legacy [env] section. "
494 "Please rename it to [env:default]."
495 )
496 env_sections["default"] = dict(parser.items(section_name))
497 continue
499 # -- Handle the [env:env-name] sections.
500 tokes = section_name.split(":")
501 if len(tokes) == 2 and tokes[0] == "env": 501 ↛ 507line 501 didn't jump to line 507 because the condition on line 501 was always true
502 env_name = tokes[1]
503 env_sections[env_name] = dict(parser.items(section_name))
504 continue
506 # -- Handle unknown section name.
507 cerror(f"Invalid section name '{section_name}' in apio.ini.")
508 cout(
509 "The valid section names are [apio], [common], and [env:env-name]",
510 style=INFO,
511 )
512 sys.exit(1)
514 # -- Construct the Project object. Its constructor validates the options.
515 return Project(
516 apio_section=apio_section or {},
517 common_section=common_section or {},
518 env_sections=env_sections,
519 env_arg=env_arg,
520 boards=boards,
521 )
524def create_project_file(
525 project_dir: Path,
526 board_id: str,
527 top_module: str,
528):
529 """Creates a new basic apio project file. Exits on any error."""
531 # -- Construct the path
532 ini_path = project_dir / "apio.ini"
534 # -- Error if apio.ini already exists.
535 if ini_path.exists():
536 cerror("The file apio.ini already exists.")
537 sys.exit(1)
539 # -- Construct and write the apio.ini file..
540 cout(f"Creating {ini_path} file ...")
542 section_name = "env:default"
544 config = ConfigObj(str(ini_path))
545 config.initial_comment = TOP_COMMENT.split("\n")
546 config[section_name] = {}
547 config[section_name]["board"] = board_id
548 config[section_name]["top-module"] = top_module
550 config.write()
551 cout(f"The file '{ini_path}' was created successfully.", style=SUCCESS)