Coverage for apio/apio_context.py: 91%
148 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"""The apio context."""
11import os
12from enum import Enum, unique
13from pathlib import Path
14import json
15from apio.common import subprocess_env, apio_platforms
16from apio.common.apio_console import cout, cstyle, fatal_error
17from apio.common.apio_styles import INFO, EMPH1
18from apio.common.common_util import env_build_path
19from apio.common.proto.stubs.apio_common_pb2 import EnvMutations
20from apio.managers.profile import Profile
21from apio.managers.remote_config import RemoteConfig, RemoteConfigPolicy
22from apio.utils import util, env_options, jsonc
23from apio.common.proto.stubs.apio_config_pb2 import ApioPackageSpec
24from apio.utils.resource_util import ApioConfig, read_apio_config_file
25from apio.common.apio_platforms import ApioPlatform
26from apio.managers.project import Project, load_project_from_file
27from apio.managers.package_manager import PackageManager
28from apio.managers.apio_definitions import (
29 ApioDefinitions,
30 ProjectDefinitions,
31 collect_project_definitions,
32)
35@unique
36class ProjectPolicy(Enum):
37 """Represents the possible context policies regarding loading apio.ini.
38 and project related information."""
40 # -- Project information is not loaded.
41 NO_PROJECT = 1
42 # -- Project information is loaded if apio.ini is found.
43 PROJECT_OPTIONAL = 2
44 # -- Apio.ini is required and project information must be loaded.
45 PROJECT_REQUIRED = 3
48@unique
49class PackagesPolicy(Enum):
50 """Represents the possible context policies regarding loading apio.ini.
51 and project related information."""
53 # -- Do not change the package state, they may exist or not, updated or
54 # -- not. This policy requires project policy NO_PROJECT and with it,
55 # -- the definitions are not loaded.
56 IGNORE_PACKAGES = 1
57 # -- Normal policy, verify that the packages are installed correctly and
58 # -- update them if needed.
59 ENSURE_PACKAGES = 2
62class ApioContext:
63 """Apio context. Class for accessing apio resources and configurations."""
65 # pylint: disable=too-many-instance-attributes
67 # -- List of allowed instance vars.
68 __slots__ = (
69 "project_policy",
70 "apio_home_dir",
71 "apio_packages_dir",
72 "config",
73 "profile",
74 "remote_config",
75 "package_manager",
76 "platform",
77 "_project_dir",
78 "_project",
79 "_project_resources",
80 "definitions",
81 )
83 def __init__(
84 self,
85 *,
86 project_policy: ProjectPolicy,
87 remote_config_policy: RemoteConfigPolicy,
88 packages_policy: PackagesPolicy,
89 project_dir_arg: Path | None = None,
90 env_arg: str | None = None,
91 report_env=True,
92 ):
93 """Initializes the ApioContext object.
95 'project_policy', 'config_policy', and 'packages_policy' are modifiers
96 that controls the initialization of the context.
98 'project_dir_arg' is an optional user specification of the project dir.
99 Must be None if project_policy is NO_PROJECT.
101 'env_arg' is an optional command line option value that select the
102 apio.ini env if the project is loaded. it makes sense only when
103 project_policy is PROJECT_REQUIRED (enforced by an assertion).
105 If an apio.ini project is loaded, the method prints to the user the
106 selected env and board, unless if report_env = False.
107 """
109 # pylint: disable=too-many-arguments
111 # -- Sanity check the policies.
112 assert isinstance(project_policy, ProjectPolicy)
113 assert isinstance(remote_config_policy, RemoteConfigPolicy)
114 assert isinstance(packages_policy, PackagesPolicy)
116 if packages_policy == PackagesPolicy.IGNORE_PACKAGES:
117 assert project_policy == ProjectPolicy.NO_PROJECT
119 # -- Inform as soon as possible about the list of apio env options
120 # -- that modify its default behavior.
121 defined_env_options = env_options.get_defined()
122 if defined_env_options: 122 ↛ 129line 122 didn't jump to line 129 because the condition on line 122 was always true
123 cout(
124 f"Active env options [{', '.join(defined_env_options)}].",
125 style=INFO,
126 )
128 # -- Store the project_policy
129 assert isinstance(
130 project_policy, ProjectPolicy
131 ), "Not an ApioContextScope"
132 self.project_policy = project_policy
134 # -- Sanity check, env_arg makes sense only when project_policy is
135 # -- PROJECT_REQUIRED.
136 if env_arg is not None:
137 assert project_policy == ProjectPolicy.PROJECT_REQUIRED
139 # -- Determine if we need to load the project, and if so, set
140 # -- self._project_dir to the project dir, otherwise, leave it None.
141 self._project_dir: Path | None = None
142 if project_policy == ProjectPolicy.PROJECT_REQUIRED:
143 self._project_dir = util.user_directory_or_cwd(
144 project_dir_arg, description="Project", must_exist=True
145 )
146 elif project_policy == ProjectPolicy.PROJECT_OPTIONAL:
147 project_dir = util.user_directory_or_cwd(
148 project_dir_arg, description="Project", must_exist=False
149 )
150 if (project_dir / "apio.ini").exists():
151 self._project_dir = project_dir
152 else:
153 assert (
154 project_policy == ProjectPolicy.NO_PROJECT
155 ), f"Unexpected project policy: {project_policy}"
156 assert (
157 project_dir_arg is None
158 ), "project_dir_arg specified for project policy None"
160 # -- Determine apio home and packages dirs
161 self.apio_home_dir: Path = util.resolve_home_dir()
162 self.apio_packages_dir: Path = util.resolve_packages_dir(
163 self.apio_home_dir
164 )
166 # -- Get the underlying platform information.
167 self.platform: ApioPlatform = apio_platforms.get_apio_platform()
169 # -- Read and validate the config information from config.jsonc.
170 self.config: ApioConfig = read_apio_config_file(
171 self.platform, self.apio_packages_dir
172 )
174 # -- Read the user profile from ~/.apio/profile.json.
175 self.profile = Profile(
176 self.apio_home_dir,
177 )
179 # -- Read remote config information, from local cache or remotely..
180 remote_config_url = env_options.get(
181 env_options.APIO_REMOTE_CONFIG_URL,
182 default=self.config.remote_config.url,
183 )
184 assert isinstance(remote_config_url, str), remote_config_url
186 self.remote_config = RemoteConfig(
187 self.apio_home_dir,
188 remote_config_url,
189 self.config.remote_config.ttl_days,
190 self.config.remote_config.retry_minutes,
191 remote_config_policy,
192 )
194 # -- Instantiate the package manager. All self.* args were already
195 # -- initialized above.
196 self.package_manager: PackageManager = PackageManager(
197 remote_config=self.remote_config,
198 platform_packages=self.platform_packages,
199 platform=self.platform,
200 apio_home_dir=self.apio_home_dir,
201 packages_dir=self.apio_packages_dir,
202 )
204 # -- Apply package policy
206 # -- Case 1: IGNORE_PACKAGES
207 if packages_policy == PackagesPolicy.IGNORE_PACKAGES:
208 self.definitions = None
210 # -- Case 2: ENSURE_PACKAGES
211 else:
212 assert packages_policy == PackagesPolicy.ENSURE_PACKAGES
214 # -- Install missing packages. At this point, the fields that are
215 # -- required by self.package_manager are already initialized.
216 # --
217 # -- TODO: Set verbose=True if APIO_DEBUG is above some level.
218 self.package_manager.install_missing_packages_on_the_fly(
219 verbose=False
220 )
222 # -- Load the boards, fpgas, and programmer definitions, including
223 # -- optional custom overrides in project's dir.
224 self.definitions = ApioDefinitions(
225 self.apio_packages_dir,
226 self._project_dir,
227 )
229 # -- If we determined that we need to load the project, load the
230 # -- apio.ini data.
231 self._project: Project | None = None
232 self._project_resources: ProjectDefinitions | None = None
234 if self._project_dir:
235 # -- If we have a project, we must also have definitions.
236 assert self.definitions is not None
238 # -- Load the project object
239 self._project = load_project_from_file(
240 self._project_dir, env_arg, self.definitions.boards
241 )
242 assert self.has_project, "init(): project not loaded"
243 # -- Inform the user about the active env, if needed..
244 if report_env:
245 self.report_project_env()
246 # -- Collect and validate the project resources.
247 # -- The project is already validated to have the required "board.
248 self._project_resources = collect_project_definitions(
249 self._project.get_str_option("board"),
250 self.definitions,
251 )
252 else:
253 assert not self.has_project, "init(): project loaded"
255 def report_project_env(self):
256 """Report to the user the env and board used. Asserts that the
257 project is loaded."""
258 # -- Do not call if project is not loaded.
259 assert self.has_project
261 # -- Env name string in color
262 styled_env_name = cstyle(self.project.env_name, style=EMPH1)
264 # -- Board id string in color
265 styled_board_id = cstyle(
266 self.project.get_str_option("board"), style=EMPH1
267 )
269 # -- Report.
270 cout(f"Using env {styled_env_name} ({styled_board_id})")
272 @property
273 def all_packages(self) -> dict[str, ApioPackageSpec]:
274 """Return a dict of package names -> package specs for all packages."""
275 return self.config.all_packages
277 @property
278 def platform_packages(self) -> dict[str, ApioPackageSpec]:
279 """Return a dict of package names -> package specs for all packages."""
280 return self.config.platform_packages
282 @property
283 def has_project(self):
284 """Returns True if the project is loaded."""
285 return self._project is not None
287 @property
288 def project_dir(self):
289 """Returns the project dir. Should be called only if has_project_loaded
290 is true."""
291 assert self.has_project, "project_dir(): project is not loaded"
292 assert self._project_dir, "project_dir(): missing value."
293 return self._project_dir
295 @property
296 def project(self) -> Project:
297 """Return the project. Should be called only if has_project() is
298 True."""
299 # -- Failure here is a programming error, not a user error.
300 assert self.has_project, "project(): project is not loaded"
301 assert self._project is not None
302 return self._project
304 @property
305 def project_resources(self) -> ProjectDefinitions:
306 """Return the project resources. Should be called only if
307 has_project() is True."""
308 # -- Failure here is a programming error, not a user error.
309 assert self.has_project, "project(): project is not loaded"
310 assert self._project_resources is not None
311 return self._project_resources
313 @property
314 def env_build_path(self) -> Path:
315 """Returns the relative path of the current env build directory from
316 the project dir. Should be called only when has_project is True."""
317 assert self.has_project, "project(): project is not loaded"
318 return env_build_path(self.project.env_name)
320 @classmethod
321 def _load_resource_file(cls, name: str, resources_dir: Path) -> dict:
322 """Load a .jsonc resource file and return its content as a
323 json dict."""
325 # pylint: disable=broad-exception-caught
327 # -- Construct file path.
328 filepath = resources_dir / name
330 # -- Read the and parse the jsonc file
331 try:
332 jsonc_text = filepath.read_text(encoding="utf-8")
333 json_text = jsonc.to_json(jsonc_text)
334 json_dict = json.loads(json_text)
335 except Exception as e:
337 fatal_error(
338 f"Failed to read and parse resource file {name}", cause=e
339 )
341 # -- Return the object for the resource
342 return json_dict
344 def get_package_dir(self, package_name: str) -> Path:
345 """Returns the root path of a package with given name."""
347 return self.apio_packages_dir / package_name
349 def get_tmp_dir(self, create: bool = True) -> Path:
350 """Return the tmp dir under the apio home dir. If 'create' is true
351 create the dir and its parents if they do not exist."""
352 tmp_dir = self.apio_home_dir / "tmp"
353 if create:
354 tmp_dir.mkdir(parents=True, exist_ok=True)
355 return tmp_dir
357 @property
358 def is_linux(self) -> bool:
359 """Returns True iff underlying platform is a Linux."""
360 return self.platform.is_linux
362 @property
363 def is_darwin(self) -> bool:
364 """Returns True iff underlying platform is a Mac OSX."""
365 return self.platform.is_darwin
367 @property
368 def is_windows(self) -> bool:
369 """Returns True iff underlying platform is a Windows."""
370 return self.platform.is_windows
372 def get_env_mutations_for_subprocess(
373 self, *, include_apio_packages: bool
374 ) -> EnvMutations:
375 """Get the environment mutations for the tools that apio runs in
376 a sub process.
377 """
379 return subprocess_env.get_env_mutations_for_subprocess(
380 self.platform,
381 util.is_pyinstaller_app(),
382 self.config.platform_env if include_apio_packages else None,
383 )
385 def get_env_for_subprocess(
386 self,
387 *,
388 include_apio_packages: bool,
389 quiet: bool = False,
390 verbose: bool = False,
391 ) -> dict[str, str]:
392 """Return the env to pass to tools subprocesses such as Yosys.
394 If quite is set, no output is printed. When verbose is set, additional
395 output such as the env vars mutations are printed, otherwise, a minimal
396 information is printed to make the user aware that they commands they
397 see are executed in a modified env settings.
398 """
400 # -- If this fails, this is a programming error. Quiet and verbose
401 # -- cannot be combined.
402 assert not (quiet and verbose), "Can't have both quite and verbose."
404 # -- Collect the env mutations from all packages.
405 mutations = self.get_env_mutations_for_subprocess(
406 include_apio_packages=include_apio_packages
407 )
409 if verbose:
410 subprocess_env.show_env_mutations(mutations, self.platform)
412 # -- Make an independent copy of os.environ.
413 env: dict[str, str] = os.environ.copy()
415 # -- Apply the mutations to the copy.
416 if not verbose and not quiet:
417 cout("Setting shell vars.")
419 subprocess_env.apply_env_mutations(mutations, env)
421 # -- All done.
422 return env
424 def scons_shell_id(self) -> str:
425 """
426 Returns a simplified string name of the shell that SCons will use
427 for executing shell-dependent commands. See code below for possible
428 values.
429 """
430 return subprocess_env.scons_shell_id(self.platform)