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

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 

8 

9"""The apio context.""" 

10 

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) 

33 

34 

35@unique 

36class ProjectPolicy(Enum): 

37 """Represents the possible context policies regarding loading apio.ini. 

38 and project related information.""" 

39 

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 

46 

47 

48@unique 

49class PackagesPolicy(Enum): 

50 """Represents the possible context policies regarding loading apio.ini. 

51 and project related information.""" 

52 

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 

60 

61 

62class ApioContext: 

63 """Apio context. Class for accessing apio resources and configurations.""" 

64 

65 # pylint: disable=too-many-instance-attributes 

66 

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 ) 

82 

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. 

94 

95 'project_policy', 'config_policy', and 'packages_policy' are modifiers 

96 that controls the initialization of the context. 

97 

98 'project_dir_arg' is an optional user specification of the project dir. 

99 Must be None if project_policy is NO_PROJECT. 

100 

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). 

104 

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 """ 

108 

109 # pylint: disable=too-many-arguments 

110 

111 # -- Sanity check the policies. 

112 assert isinstance(project_policy, ProjectPolicy) 

113 assert isinstance(remote_config_policy, RemoteConfigPolicy) 

114 assert isinstance(packages_policy, PackagesPolicy) 

115 

116 if packages_policy == PackagesPolicy.IGNORE_PACKAGES: 

117 assert project_policy == ProjectPolicy.NO_PROJECT 

118 

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 ) 

127 

128 # -- Store the project_policy 

129 assert isinstance( 

130 project_policy, ProjectPolicy 

131 ), "Not an ApioContextScope" 

132 self.project_policy = project_policy 

133 

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 

138 

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" 

159 

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 ) 

165 

166 # -- Get the underlying platform information. 

167 self.platform: ApioPlatform = apio_platforms.get_apio_platform() 

168 

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 ) 

173 

174 # -- Read the user profile from ~/.apio/profile.json. 

175 self.profile = Profile( 

176 self.apio_home_dir, 

177 ) 

178 

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 

185 

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 ) 

193 

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 ) 

203 

204 # -- Apply package policy 

205 

206 # -- Case 1: IGNORE_PACKAGES 

207 if packages_policy == PackagesPolicy.IGNORE_PACKAGES: 

208 self.definitions = None 

209 

210 # -- Case 2: ENSURE_PACKAGES 

211 else: 

212 assert packages_policy == PackagesPolicy.ENSURE_PACKAGES 

213 

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 ) 

221 

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 ) 

228 

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 

233 

234 if self._project_dir: 

235 # -- If we have a project, we must also have definitions. 

236 assert self.definitions is not None 

237 

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" 

254 

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 

260 

261 # -- Env name string in color 

262 styled_env_name = cstyle(self.project.env_name, style=EMPH1) 

263 

264 # -- Board id string in color 

265 styled_board_id = cstyle( 

266 self.project.get_str_option("board"), style=EMPH1 

267 ) 

268 

269 # -- Report. 

270 cout(f"Using env {styled_env_name} ({styled_board_id})") 

271 

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 

276 

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 

281 

282 @property 

283 def has_project(self): 

284 """Returns True if the project is loaded.""" 

285 return self._project is not None 

286 

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 

294 

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 

303 

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 

312 

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) 

319 

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.""" 

324 

325 # pylint: disable=broad-exception-caught 

326 

327 # -- Construct file path. 

328 filepath = resources_dir / name 

329 

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: 

336 

337 fatal_error( 

338 f"Failed to read and parse resource file {name}", cause=e 

339 ) 

340 

341 # -- Return the object for the resource 

342 return json_dict 

343 

344 def get_package_dir(self, package_name: str) -> Path: 

345 """Returns the root path of a package with given name.""" 

346 

347 return self.apio_packages_dir / package_name 

348 

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 

356 

357 @property 

358 def is_linux(self) -> bool: 

359 """Returns True iff underlying platform is a Linux.""" 

360 return self.platform.is_linux 

361 

362 @property 

363 def is_darwin(self) -> bool: 

364 """Returns True iff underlying platform is a Mac OSX.""" 

365 return self.platform.is_darwin 

366 

367 @property 

368 def is_windows(self) -> bool: 

369 """Returns True iff underlying platform is a Windows.""" 

370 return self.platform.is_windows 

371 

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 """ 

378 

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 ) 

384 

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. 

393 

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 """ 

399 

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." 

403 

404 # -- Collect the env mutations from all packages. 

405 mutations = self.get_env_mutations_for_subprocess( 

406 include_apio_packages=include_apio_packages 

407 ) 

408 

409 if verbose: 

410 subprocess_env.show_env_mutations(mutations, self.platform) 

411 

412 # -- Make an independent copy of os.environ. 

413 env: dict[str, str] = os.environ.copy() 

414 

415 # -- Apply the mutations to the copy. 

416 if not verbose and not quiet: 

417 cout("Setting shell vars.") 

418 

419 subprocess_env.apply_env_mutations(mutations, env) 

420 

421 # -- All done. 

422 return env 

423 

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)