Coverage for apio/apio_context.py: 86%

299 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 01:55 +0000

1"""The apio context.""" 

2 

3# -*- coding: utf-8 -*- 

4# -- This file is part of the Apio project 

5# -- (C) 2016-2019 FPGAwars 

6# -- Author Jesús Arroyo 

7# -- License GPLv2 

8 

9import os 

10import sys 

11import json 

12import platform 

13from dataclasses import dataclass 

14from enum import Enum 

15from pathlib import Path 

16from typing import List, Optional, Dict 

17from apio.common.apio_console import cout, cerror, cstyle 

18from apio.common.apio_styles import INFO, EMPH1, EMPH2, EMPH3 

19from apio.common.common_util import env_build_path 

20from apio.profile import Profile, RemoteConfigPolicy 

21from apio.utils import jsonc, util, env_options, apio_platforms 

22from apio.utils.apio_platforms import ApioPlatform 

23from apio.managers.project import Project, load_project_from_file 

24from apio.managers import packages 

25from apio.managers.packages import PackagesContext 

26from apio.utils.resource_util import ( 

27 ProjectResources, 

28 collect_project_resources, 

29 validate_project_resources, 

30 validate_config, 

31 validate_packages, 

32) 

33 

34# ---------- RESOURCES 

35RESOURCES_DIR = "resources" 

36 

37 

38# --------------------------------------- 

39# ---- File: resources/packages.jsonc 

40# -------------------------------------- 

41# -- This file contains all the information regarding the available apio 

42# -- packages: Repository, version, name... 

43PACKAGES_JSONC = "packages.jsonc" 

44 

45# ----------------------------------------- 

46# ---- File: resources/boards.jsonc 

47# ----------------------------------------- 

48# -- Information about all the supported boards 

49# -- names, fpga family, programmer, ftdi description, vendor id, product id 

50BOARDS_JSONC = "boards.jsonc" 

51 

52# ----------------------------------------- 

53# ---- File: resources/fpgas.jsonc 

54# ----------------------------------------- 

55# -- Information about all the supported fpgas 

56# -- arch, type, size, packaging 

57FPGAS_JSONC = "fpgas.jsonc" 

58 

59# ----------------------------------------- 

60# ---- File: resources/programmers.jsonc 

61# ----------------------------------------- 

62# -- Information about all the supported programmers 

63# -- name, command to execute, arguments... 

64PROGRAMMERS_JSONC = "programmers.jsonc" 

65 

66# ----------------------------------------- 

67# ---- File: resources/config.jsonc 

68# ----------------------------------------- 

69# -- General config information. 

70CONFIG_JSONC = "config.jsonc" 

71 

72 

73@dataclass(frozen=True) 

74class ApioDefinitions: 

75 """Contains the apio definitions in the form of json dictionaries.""" 

76 

77 # -- A json dir with the content of boards.jsonc 

78 boards: dict 

79 # -- A json dir with the content of fpgas.jsonc 

80 fpgas: dict 

81 # -- A json dir with the content of programmers.jsonc. 

82 programmers: dict 

83 

84 def __post_init__(self): 

85 """Assert that all fields initialized to actual values.""" 

86 assert self.boards 

87 assert self.fpgas 

88 assert self.programmers 

89 

90 

91@dataclass(frozen=True) 

92class EnvMutations: 

93 """Contains mutations to the system env.""" 

94 

95 # -- List of env vars to unset. 

96 unset_vars: List[str] 

97 

98 # -- PATH items to add. 

99 paths: List[str] 

100 

101 # -- Dict with env vars name/value to set. 

102 set_vars: Dict[str, str] 

103 

104 

105class ProjectPolicy(Enum): 

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

107 and project related information.""" 

108 

109 # -- Project information is not loaded. 

110 NO_PROJECT = 1 

111 # -- Project information is loaded if apio.ini is found. 

112 PROJECT_OPTIONAL = 2 

113 # -- Apio.ini is required and project information must be loaded. 

114 PROJECT_REQUIRED = 3 

115 

116 

117class PackagesPolicy(Enum): 

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

119 and project related information.""" 

120 

121 # -- Do not change the package state, they may exist or not, updated or 

122 # -- not. This policy requires project policy NO_PROJECT and with it, 

123 # -- the definitions are not loaded. 

124 IGNORE_PACKAGES = 1 

125 # -- Normal policy, verify that the packages are installed correctly and 

126 # -- update them if needed. 

127 ENSURE_PACKAGES = 2 

128 

129 

130class ApioContext: 

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

132 

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

134 

135 # -- List of allowed instance vars. 

136 __slots__ = ( 

137 "project_policy", 

138 "apio_home_dir", 

139 "apio_packages_dir", 

140 "config", 

141 "profile", 

142 "platform", 

143 "platform_id", 

144 "scons_shell_id", 

145 "all_packages", 

146 "required_packages", 

147 "env_was_already_set", 

148 "_project_dir", 

149 "_project", 

150 "_project_resources", 

151 "_definitions", 

152 ) 

153 

154 def __init__( 

155 self, 

156 *, 

157 project_policy: ProjectPolicy, 

158 remote_config_policy: RemoteConfigPolicy, 

159 packages_policy: PackagesPolicy, 

160 project_dir_arg: Optional[Path] = None, 

161 env_arg: Optional[str] = None, 

162 report_env=True, 

163 ): 

164 """Initializes the ApioContext object. 

165 

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

167 that controls the initialization of the context. 

168 

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

170 Must be None if project_policy is NO_PROJECT. 

171 

172 'env_arg' is an optional command line option value that select the 

173 apio.ini env if the project is loaded. it makes sense only when 

174 project_policy is PROJECT_REQUIRED (enforced by an assertion). 

175 

176 If an apio.ini project is loaded, the method prints to the user the 

177 selected env and board, unless if report_env = False. 

178 """ 

179 

180 # pylint: disable=too-many-arguments 

181 # pylint: disable=too-many-statements 

182 # pylint: disable=too-many-locals 

183 

184 # -- Sanity check the policies. 

185 assert isinstance(project_policy, ProjectPolicy) 

186 assert isinstance(remote_config_policy, RemoteConfigPolicy) 

187 assert isinstance(packages_policy, PackagesPolicy) 

188 

189 if packages_policy == PackagesPolicy.IGNORE_PACKAGES: 

190 assert project_policy == ProjectPolicy.NO_PROJECT 

191 

192 # -- Inform as soon as possible about the list of apio env options 

193 # -- that modify its default behavior. 

194 defined_env_options = env_options.get_defined() 

195 if defined_env_options: 195 ↛ 202line 195 didn't jump to line 202 because the condition on line 195 was always true

196 cout( 

197 f"Active env options [{', '.join(defined_env_options)}].", 

198 style=INFO, 

199 ) 

200 

201 # -- Store the project_policy 

202 assert isinstance( 

203 project_policy, ProjectPolicy 

204 ), "Not an ApioContextScope" 

205 self.project_policy = project_policy 

206 

207 # -- Sanity check, env_arg makes sense only when project_policy is 

208 # -- PROJECT_REQUIRED. 

209 if env_arg is not None: 

210 assert project_policy == ProjectPolicy.PROJECT_REQUIRED 

211 

212 # -- A flag to indicate if the system env was already set in this 

213 # -- apio session. Used to avoid multiple repeated settings that 

214 # -- make the path longer and longer. 

215 self.env_was_already_set = False 

216 

217 # -- Determine if we need to load the project, and if so, set 

218 # -- self._project_dir to the project dir, otherwise, leave it None. 

219 self._project_dir: Path | None = None 

220 if project_policy == ProjectPolicy.PROJECT_REQUIRED: 

221 self._project_dir = util.user_directory_or_cwd( 

222 project_dir_arg, description="Project", must_exist=True 

223 ) 

224 elif project_policy == ProjectPolicy.PROJECT_OPTIONAL: 

225 project_dir = util.user_directory_or_cwd( 

226 project_dir_arg, description="Project", must_exist=False 

227 ) 

228 if (project_dir / "apio.ini").exists(): 

229 self._project_dir = project_dir 

230 else: 

231 assert ( 

232 project_policy == ProjectPolicy.NO_PROJECT 

233 ), f"Unexpected project policy: {project_policy}" 

234 assert ( 

235 project_dir_arg is None 

236 ), "project_dir_arg specified for project policy None" 

237 

238 # -- Determine apio home and packages dirs 

239 self.apio_home_dir: Path = util.resolve_home_dir() 

240 self.apio_packages_dir: Path = util.resolve_packages_dir( 

241 self.apio_home_dir 

242 ) 

243 

244 # -- Get the jsonc source dirs. 

245 resources_dir = util.get_path_in_apio_package(RESOURCES_DIR) 

246 

247 # -- Read and validate the config information 

248 self.config = self._load_resource(CONFIG_JSONC, resources_dir) 

249 validate_config(self.config) 

250 

251 # -- Profile information, from ~/.apio/profile.json. We provide it with 

252 # -- the remote config url template from distribution.jsonc such that 

253 # -- can it fetch the remote config on demand. 

254 remote_config_url = env_options.get( 

255 env_options.APIO_REMOTE_CONFIG_URL, 

256 default=self.config["remote-config-url"], 

257 ) 

258 remote_config_ttl_days = self.config["remote-config-ttl-days"] 

259 remote_config_retry_minutes = self.config[ 

260 "remote-config-retry-minutes" 

261 ] 

262 self.profile = Profile( 

263 self.apio_home_dir, 

264 self.apio_packages_dir, 

265 str(remote_config_url), 

266 remote_config_ttl_days, 

267 remote_config_retry_minutes, 

268 remote_config_policy, 

269 ) 

270 

271 # -- Get the underlying platform information. 

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

273 self.platform_id: str = self.platform.id 

274 

275 # -- Determine the shell id that scons will use. 

276 # -- See _determine_scons_shell_id() for possible values. 

277 self.scons_shell_id = self._determine_scons_shell_id(self.platform) 

278 

279 # -- Read the apio packages information 

280 self.all_packages = self._load_resource(PACKAGES_JSONC, resources_dir) 

281 validate_packages(self.all_packages) 

282 

283 # -- Expand in place the env templates in all_packages. 

284 ApioContext._resolve_package_envs( 

285 self.all_packages, self.apio_packages_dir 

286 ) 

287 

288 # The subset of packages that are applicable to this platform. 

289 self.required_packages = self._select_required_packages_for_platform( 

290 self.all_packages, 

291 self.platform_id, 

292 ) 

293 

294 # -- Case 1: IGNORE_PACKAGES 

295 if packages_policy == PackagesPolicy.IGNORE_PACKAGES: 

296 self._definitions = None 

297 

298 # -- Case 2: ENSURE_PACKAGES 

299 else: 

300 assert packages_policy == PackagesPolicy.ENSURE_PACKAGES 

301 

302 # -- Install missing packages. At this point, the fields that are 

303 # -- required by self.packages_context are already initialized. 

304 packages.install_missing_packages_on_the_fly( 

305 self.packages_context, verbose=False 

306 ) 

307 

308 # -- Load the definitions from the definitions file with possible 

309 # -- override by the optional project file. 

310 definitions_dir = self.apio_packages_dir / "definitions" 

311 boards = self._load_resource( 

312 BOARDS_JSONC, definitions_dir, self._project_dir 

313 ) 

314 fpgas = self._load_resource( 

315 FPGAS_JSONC, definitions_dir, self._project_dir 

316 ) 

317 programmers = self._load_resource( 

318 PROGRAMMERS_JSONC, definitions_dir, self._project_dir 

319 ) 

320 self._definitions = ApioDefinitions(boards, fpgas, programmers) 

321 

322 # -- If we determined that we need to load the project, load the 

323 # -- apio.ini data. 

324 self._project: Optional[Project] = None 

325 self._project_resources: ProjectResources | None = None 

326 

327 if self._project_dir: 

328 # -- Load the project object 

329 self._project = load_project_from_file( 

330 self._project_dir, env_arg, self.boards 

331 ) 

332 assert self.has_project, "init(): project not loaded" 

333 # -- Inform the user about the active env, if needed.. 

334 if report_env: 

335 self.report_env() 

336 # -- Collect and validate the project resources. 

337 # -- The project is already validated to have the required "board. 

338 self._project_resources = collect_project_resources( 

339 self._project.get_str_option("board"), 

340 self.boards, 

341 self.fpgas, 

342 self.programmers, 

343 ) 

344 # -- Validate the project resources. 

345 validate_project_resources(self._project_resources) 

346 else: 

347 assert not self.has_project, "init(): project loaded" 

348 

349 def report_env(self): 

350 """Report to the user the env and board used. Asserts that the 

351 project is loaded.""" 

352 # -- Do not call if project is not loaded. 

353 assert self.has_project 

354 

355 # -- Env name string in color 

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

357 

358 # -- Board id string in color 

359 styled_board_id = cstyle( 

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

361 ) 

362 

363 # -- Report. 

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

365 

366 @property 

367 def has_project(self): 

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

369 return self._project is not None 

370 

371 @property 

372 def project_dir(self): 

373 """Returns the project dir. Should be called only if has_project_loaded 

374 is true.""" 

375 assert self.has_project, "project_dir(): project is not loaded" 

376 assert self._project_dir, "project_dir(): missing value." 

377 return self._project_dir 

378 

379 @property 

380 def project(self) -> Project: 

381 """Return the project. Should be called only if has_project() is 

382 True.""" 

383 # -- Failure here is a programming error, not a user error. 

384 assert self.has_project, "project(): project is not loaded" 

385 return self._project # pyright: ignore[reportReturnType] 

386 

387 @property 

388 def project_resources(self) -> ProjectResources: 

389 """Return the project resources. Should be called only if 

390 has_project() is True.""" 

391 # -- Failure here is a programming error, not a user error. 

392 assert self.has_project, "project(): project is not loaded" 

393 return self._project_resources # pyright: ignore[reportReturnType] 

394 

395 @property 

396 def definitions(self) -> ApioDefinitions: 

397 """Return apio definitions.""" 

398 assert self._definitions, "Apio context as no definitions" 

399 return self._definitions 

400 

401 @property 

402 def boards(self) -> dict: 

403 """Returns the apio board definitions""" 

404 return self.definitions.boards 

405 

406 @property 

407 def fpgas(self) -> dict: 

408 """Returns the apio fpgas definitions""" 

409 return self.definitions.fpgas 

410 

411 @property 

412 def programmers(self) -> dict: 

413 """Returns the apio programmers definitions""" 

414 return self.definitions.programmers 

415 

416 @property 

417 def env_build_path(self) -> Path: 

418 """Returns the relative path of the current env build directory from 

419 the project dir. Should be called only when has_project is True.""" 

420 assert self.has_project, "project(): project is not loaded" 

421 return env_build_path(self.project.env_name) 

422 

423 def _load_resource( 

424 self, name: str, standard_dir: Path, custom_dir: Optional[Path] = None 

425 ) -> dict: 

426 """Load a jsonc file. Try first from custom_dir, if given, and then 

427 from standard dir. This method is called for resource files in 

428 apio/resources and definitions files in the definitions packages. 

429 """ 

430 

431 # -- Load the standard definition as a json dict. 

432 filepath = standard_dir / name 

433 result = self._load_resource_file(filepath) 

434 

435 # -- If there is a project specific override file, apply it on 

436 # -- top of the standard apio definition dict. 

437 if custom_dir: 

438 filepath = custom_dir / name 

439 if filepath.exists(): 

440 # -- Load the override json dict. 

441 cout(f"Loading custom '{name}'.") 

442 override = self._load_resource_file(filepath) 

443 # -- Apply the override. Entries in override replace same 

444 # -- key entries in result or if unique are added. 

445 result.update(override) 

446 

447 # -- All done. 

448 return result 

449 

450 @staticmethod 

451 def _load_resource_file(filepath: Path) -> dict: 

452 """Load the resources from a given jsonc file path 

453 * OUTPUT: A dictionary with the jsonc file data 

454 In case of error it raises an exception and finish 

455 """ 

456 

457 # -- Read the jsonc file 

458 try: 

459 with filepath.open(encoding="utf8") as file: 

460 

461 # -- Read the json with comments file 

462 data_jsonc = file.read() 

463 

464 # -- The jsonc file NOT FOUND! This is an apio system error 

465 # -- It should never occur unless there is a bug in the 

466 # -- apio system files, or a bug when calling this function 

467 # -- passing a wrong file 

468 except FileNotFoundError as exc: 

469 

470 # -- Display error information 

471 cerror("[Internal] .jsonc file not found", f"{exc}") 

472 

473 # -- Abort! 

474 sys.exit(1) 

475 

476 # -- Convert the jsonc to json by removing '//' comments. 

477 data_json = jsonc.to_json(data_jsonc) 

478 

479 # -- Parse the json format! 

480 try: 

481 resource = json.loads(data_json) 

482 

483 # -- Invalid json format! This is an apio system error 

484 # -- It should never occur unless a developer has 

485 # -- made a mistake when changing the jsonc file 

486 except json.decoder.JSONDecodeError as exc: 

487 cerror(f"'{filepath}' has bad format", f"{exc}") 

488 sys.exit(1) 

489 

490 # -- Return the object for the resource 

491 return resource 

492 

493 @staticmethod 

494 def _expand_env_values(template: str, package_path: Path) -> str: 

495 """Fills a packages env value template as they appear in 

496 packages.jsonc. Currently it recognizes only a single place holder 

497 '%p' representing the package absolute path. The '%p" can appear only 

498 at the beginning of the template. 

499 

500 E.g. '%p/bin' -> '/users/user/.apio/packages/drivers/bin' 

501 

502 NOTE: This format is very basic but is sufficient for the current 

503 needs. If needed, extend or modify it. 

504 """ 

505 

506 # Case 1: No place holder -> no change. 

507 if "%p" not in template: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 return template 

509 

510 # Case 2: The template contains only the placeholder. 

511 if template == "%p": 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 return str(package_path) 

513 

514 # Case 3: The place holder is the prefix of the template's path. 

515 if template.startswith("%p/"): 515 ↛ 519line 515 didn't jump to line 519 because the condition on line 515 was always true

516 return str(package_path / template[3:]) 

517 

518 # Case 4: Unsupported. 

519 raise RuntimeError(f"Invalid env template: [{template}]") 

520 

521 @staticmethod 

522 def _resolve_package_envs( 

523 packages_: Dict[str, Dict], packages_dir: Path 

524 ) -> None: 

525 """Resolve in-place the path and var value templates in the 

526 given packages dictionary. For example, %p is replaced with 

527 the package's absolute path.""" 

528 

529 for package_name, package_config in packages_.items(): 

530 

531 # -- Get the package root dir. 

532 package_path = packages_dir / package_name 

533 

534 # -- Get the json 'env' section. We require it, even if empty, 

535 # -- for clarity reasons. 

536 assert "env" in package_config 

537 package_env = package_config["env"] 

538 

539 # -- NOTE: There is no need to expand values in the "unset-env" 

540 # -- section since it contains env names only. 

541 

542 # -- Expand the values in the "path" section, if any. 

543 path_section = package_env.get("path", []) 

544 for i, path_template in enumerate(path_section): 

545 path_section[i] = ApioContext._expand_env_values( 

546 path_template, package_path 

547 ) 

548 

549 # -- Expand the values in the "set-vars" section, if any. 

550 set_vars_section = package_env.get("set-vars", {}) 

551 for var_name, var_value in set_vars_section.items(): 

552 set_vars_section[var_name] = ApioContext._expand_env_values( 

553 var_value, package_path 

554 ) 

555 

556 def get_required_package_info(self, package_name: str) -> Dict: 

557 """Returns the information of the package with given name. 

558 The information is a JSON dict originated at packages.json(). 

559 Exits with an error message if the package is not defined. 

560 """ 

561 package_info = self.required_packages.get(package_name, None) 

562 if package_info is None: 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true

563 cerror(f"Unknown package '{package_name}'") 

564 sys.exit(1) 

565 

566 return package_info 

567 

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

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

570 

571 return self.apio_packages_dir / package_name 

572 

573 def get_tmp_dir(self, create: bool = True) -> Path: 

574 """Return the tmp dir under the apio home dir. If 'create' is true 

575 create the dir and its parents if they do not exist.""" 

576 tmp_dir = self.apio_home_dir / "tmp" 

577 if create: 

578 tmp_dir.mkdir(parents=True, exist_ok=True) 

579 return tmp_dir 

580 

581 @staticmethod 

582 def _determine_scons_shell_id(apio_platform: ApioPlatform) -> str: 

583 """ 

584 Returns a simplified string name of the shell that SCons will use 

585 for executing shell-dependent commands. See code below for possible 

586 values. 

587 """ 

588 

589 # pylint: disable=too-many-return-statements 

590 

591 # -- Handle windows. 

592 if apio_platform.is_windows: 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true

593 comspec = os.environ.get("COMSPEC", "").lower() 

594 if "powershell.exe" in comspec or "pwsh.exe" in comspec: 

595 return "powershell" 

596 if "cmd.exe" in comspec: 

597 return "cmd" 

598 return "unknown" 

599 

600 # -- Handle the rest (macOS, Linux, etc.) 

601 shell_path = os.environ.get("SHELL", "").lower() 

602 if "bash" in shell_path: 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true

603 return "bash" 

604 if "zsh" in shell_path: 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true

605 return "zsh" 

606 if "fish" in shell_path: 606 ↛ 607line 606 didn't jump to line 607 because the condition on line 606 was never true

607 return "fish" 

608 if "dash" in shell_path: 608 ↛ 609line 608 didn't jump to line 609 because the condition on line 608 was never true

609 return "dash" 

610 if "ksh" in shell_path: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true

611 return "ksh" 

612 if "csh" in shell_path or "tcsh" in shell_path: 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true

613 return "cshell" 

614 return "unknown" 

615 

616 @property 

617 def packages_context(self) -> PackagesContext: 

618 """Return a PackagesContext with info extracted from this 

619 ApioContext.""" 

620 return PackagesContext( 

621 profile=self.profile, 

622 required_packages=self.required_packages, 

623 platform=self.platform, 

624 packages_dir=self.apio_packages_dir, 

625 ) 

626 

627 @staticmethod 

628 def _select_required_packages_for_platform( 

629 all_packages: Dict[str, Dict], 

630 platform_id: str, 

631 ) -> Dict: 

632 """Given a dictionary with the packages.jsonc packages infos, 

633 returns subset dictionary with packages that are available for 

634 'platform_id'. 

635 """ 

636 

637 # -- Dict of all supported platforms. 

638 all_apio_platforms = apio_platforms.get_all_apio_platforms() 

639 

640 # -- If fails, this is a programming error. 

641 assert platform_id in all_apio_platforms, platform 

642 

643 # -- Final dict with the output packages 

644 filtered_packages = {} 

645 

646 # -- Check all the packages 

647 for package_name in all_packages.keys(): 

648 

649 # -- Get the package info. 

650 package_info = all_packages[package_name] 

651 

652 # -- Get the list of platforms ids on which this package is 

653 # -- available. The package is available on all platforms unless 

654 # -- restricted by the ""restricted-to-platforms" field. 

655 required_for_platforms = package_info.get( 

656 "restricted-to-platforms", all_apio_platforms.keys() 

657 ) 

658 

659 # -- Sanity check that all platform ids are valid. If fails it's 

660 # -- a programming error. 

661 for p in required_for_platforms: 

662 assert p in all_apio_platforms.keys(), platform 

663 

664 # -- If available for 'platform_id', add it. 

665 if platform_id in required_for_platforms: 

666 filtered_packages[package_name] = all_packages[package_name] 

667 

668 # -- Return the subset dict with the packages for 'platform_id'. 

669 return filtered_packages 

670 

671 @property 

672 def is_linux(self) -> bool: 

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

674 return self.platform.is_linux 

675 

676 @property 

677 def is_darwin(self) -> bool: 

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

679 return self.platform.is_darwin 

680 

681 @property 

682 def is_windows(self) -> bool: 

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

684 return self.platform.is_windows 

685 

686 def _get_env_mutations_for_packages(self) -> EnvMutations: 

687 """Collects the env mutation for each of the defined packages, 

688 in the order they are defined.""" 

689 

690 unset_vars: List[str] = [] 

691 paths: List[str] = [] 

692 set_vars: Dict[str, str] = {} 

693 for _, package_config in self.required_packages.items(): 

694 # -- Get the json 'env' section. We require it, even if it's empty, 

695 # -- for clarity reasons. 

696 assert "env" in package_config 

697 package_env = package_config["env"] 

698 

699 # -- Collect the env vars to unset. 

700 unset_vars_section = package_env.get("unset-vars", []) 

701 for var_name in unset_vars_section: 

702 # -- Detect duplicates. 

703 assert var_name not in unset_vars, var_name 

704 unset_vars.append(var_name) 

705 

706 # -- Collect the path values. 

707 package_paths = package_env.get("path", []) 

708 paths.extend(package_paths) 

709 

710 # -- Collect the env vars to set (name, value) pairs. 

711 set_vars_section = package_env.get("set-vars", {}) 

712 for var_name, var_value in set_vars_section.items(): 

713 # -- Detect duplicates. 

714 assert var_name not in set_vars, var_name 

715 set_vars[var_name] = var_value 

716 

717 return EnvMutations(unset_vars, paths, set_vars) 

718 

719 def _dump_env_mutations(self, mutations: EnvMutations) -> None: 

720 """Dumps a user friendly representation of the env mutations.""" 

721 cout("Environment settings:", style=EMPH2) 

722 

723 # -- Print PATH mutations. 

724 windows = self.is_windows 

725 

726 # -- Print unset vars. 

727 for name in mutations.unset_vars: 

728 styled_name = cstyle(name, style=EMPH3) 

729 if windows: 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true

730 cout(f" set {styled_name}=") 

731 else: 

732 cout(f" unset {styled_name}") 

733 

734 # -- Dump paths. 

735 for p in reversed(mutations.paths): 

736 styled_name = cstyle("PATH", style=EMPH3) 

737 if windows: 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true

738 cout(f" set {styled_name}={p};%PATH%") 

739 else: 

740 cout(f' {styled_name}="{p}:$PATH"') 

741 

742 # -- Print set vars. 

743 for name, val in mutations.set_vars.items(): 

744 styled_name = cstyle(name, style=EMPH3) 

745 if windows: 745 ↛ 746line 745 didn't jump to line 746 because the condition on line 745 was never true

746 cout(f" set {styled_name}={val}") 

747 else: 

748 cout(f' {styled_name}="{val}"') 

749 

750 def _apply_env_mutations(self, mutations: EnvMutations) -> None: 

751 """Apply a given set of env mutations, while preserving their order.""" 

752 

753 # -- Apply the unset var mutations 

754 for name in mutations.unset_vars: 

755 os.environ.pop(name, None) 

756 

757 # -- Apply the path mutations, while preserving order. 

758 # -- NOTE: We treat the old path items as a single items. 

759 old_val = os.environ["PATH"] 

760 items = mutations.paths + [old_val] 

761 new_val = os.pathsep.join(items) 

762 os.environ["PATH"] = new_val 

763 

764 # -- Apply the set var mutations 

765 for name, value in mutations.set_vars.items(): 

766 os.environ[name] = value 

767 

768 def set_env_for_packages( 

769 self, *, quiet: bool = False, verbose: bool = False 

770 ) -> None: 

771 """Sets the environment variables for using all the that are 

772 available for this platform, even if currently not installed. 

773 

774 The function sets the environment only on first call and in latter 

775 calls skips the operation silently. 

776 

777 If quite is set, no output is printed. When verbose is set, additional 

778 output such as the env vars mutations are printed, otherwise, a minimal 

779 information is printed to make the user aware that they commands they 

780 see are executed in a modified env settings. 

781 """ 

782 

783 # -- If this fails, this is a programming error. Quiet and verbose 

784 # -- cannot be combined. 

785 assert not (quiet and verbose), "Can't have both quite and verbose." 

786 

787 # -- Collect the env mutations for all packages. 

788 mutations = self._get_env_mutations_for_packages() 

789 

790 if verbose: 

791 self._dump_env_mutations(mutations) 

792 

793 # -- If this is the first call in this apio invocation, apply the 

794 # -- mutations. These mutations are temporary for the lifetime of this 

795 # -- process and does not affect the user's shell environment. 

796 # -- The mutations are also inherited by child processes such as the 

797 # -- scons processes. 

798 if not self.env_was_already_set: 798 ↛ exitline 798 didn't return from function 'set_env_for_packages' because the condition on line 798 was always true

799 self._apply_env_mutations(mutations) 

800 self.env_was_already_set = True 

801 if not verbose and not quiet: 

802 cout("Setting shell vars.")