Coverage for apio/managers/package_manager.py: 75%

293 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 03:53 +0000

1# -*- coding: utf-8 -*- 

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

3# -- (C) 2016-2021 FPGAwars 

4# -- Author Jesús Arroyo 

5# -- License GPLv2 

6"""Package install/uninstall functionality. 

7Used by the 'apio packages' command. 

8""" 

9 

10import os 

11import json 

12from enum import Enum, unique 

13from datetime import datetime 

14from dataclasses import dataclass 

15from typing import Any 

16from pathlib import Path 

17import shutil 

18from apio.common.apio_console import cout, cstyle, fatal_error 

19from apio.common.apio_styles import SUCCESS, EMPH3 

20from apio.common.debug_util import is_debug 

21from apio.managers.downloader import FileDownloader 

22from apio.utils import util 

23from apio.utils.apio_platforms import ApioPlatform 

24from apio.managers.remote_config import RemoteConfig, PackageRemoteConfig 

25 

26 

27@unique 

28class RequiredPackageStatus(Enum): 

29 """Represents the classification of a required package status.""" 

30 

31 # -- NOTE: The string values here are using facing by the 

32 # -- 'apio packages list' command. 

33 

34 PACKAGE_UNINSTALLED = "Uninstalled" 

35 PACKAGE_DIR_MISSING = "Package dir missing" 

36 PACKAGE_DIR_IS_A_FILE = "Package dir is a file" 

37 PACKAGE_VERSION_MISMATCH = "Version mismatch" 

38 PACKAGE_PLATFORM_MISMATCH = "Platform mismatch" 

39 PACKAGE_APIO_VERSION_MISMATCH = "Apio version mismatch" 

40 PACKAGE_URL_MISMATCH = "Source URL mismatch" 

41 PACKAGE_OK = "OK" 

42 

43 @property 

44 def is_ok(self) -> bool: 

45 """Returns True if the status is of a legit package.""" 

46 return self == self.PACKAGE_OK 

47 

48 @property 

49 def is_inconsistency(self) -> bool: 

50 """Is it an inconsistency that requires fixing before installing the 

51 uninstalled packages.""" 

52 return self not in (self.PACKAGE_UNINSTALLED, self.PACKAGE_OK) 

53 

54 

55@unique 

56class OrphanType(Enum): 

57 """Represents the types of orphans (leftovers items).""" 

58 

59 # -- NOTE: The string values here are using facing by the 

60 # -- 'apio packages list' command. 

61 

62 # -- Non required package in installed packages index, potentially 

63 # -- it also has an entry with same name in the packages folder.. 

64 ORPHAN_PACKAGE = "Orphan package" 

65 # -- Package dir that doesn't match a required or an orphan package. 

66 ORPHAN_FILE = "Orphan file" 

67 # -- A file in the packages dir that doesn't match a name of an orphan 

68 # -- package. 

69 ORPHAN_DIR = "Orphan dir" 

70 

71 

72@dataclass 

73class PackagesScanResults: 

74 """Represents results of packages scan.""" 

75 

76 # -- Names and statuses of required packages. 

77 required_packages: dict[str, RequiredPackageStatus] 

78 

79 # -- Name and types of package, dir, and file orphans. 

80 orphans: dict[str, OrphanType] 

81 

82 def packages_installed_ok(self) -> bool: 

83 """Returns true if all the required packages are installed ok, 

84 regardless of other fixable errors.""" 

85 return all(status.is_ok for status in self.required_packages.values()) 

86 

87 def num_inconsistencies_to_fix(self) -> int: 

88 """Returns the number of inconsistencies that require fixing before 

89 installing any missing package.""" 

90 required_packages_errors = sum( 

91 1 

92 for status in self.required_packages.values() 

93 if not status.is_inconsistency 

94 ) 

95 orphans_errors = len(self.orphans) 

96 return required_packages_errors + orphans_errors 

97 

98 def is_all_ok(self) -> bool: 

99 """Return True if all packages are installed properly with no 

100 issues.""" 

101 return self.packages_installed_ok() and len(self.orphans) == 0 

102 

103 def dump(self): 

104 """Dump the content of this object. For debugging.""" 

105 cout() 

106 cout("Package scan results:") 

107 cout(f" required {self.required_packages}") 

108 cout(f" orphans {self.orphans}") 

109 

110 

111def get_datetime_stamp(dt: datetime | None = None) -> str: 

112 """Returns a string with time now as yyyy-mm-dd-hh-mm""" 

113 if dt is None: 113 ↛ 115line 113 didn't jump to line 115 because the condition on line 113 was always true

114 dt = datetime.now() 

115 return dt.strftime("%Y-%m-%d-%H-%M") 

116 

117 

118class PackageManager: 

119 """Context for package managements operations. 

120 This class provides the information needed for package management 

121 operations. This is a subset of the information contained by ApioContext 

122 and we use it, instead of passing the ApioContext, because we need to 

123 perform package management operations (e.g. updating packages) before 

124 the ApioContext object is fully initialized. 

125 """ 

126 

127 def __init__( 

128 self, 

129 remote_config: RemoteConfig, 

130 required_packages: dict, 

131 platform: ApioPlatform, 

132 apio_home_dir: Path, 

133 packages_dir: Path, 

134 ): 

135 

136 # pylint: disable=too-many-arguments 

137 # pylint: disable=too-many-positional-arguments 

138 

139 # -- Same as ApioContext.remote_config 

140 self.remote_config = remote_config 

141 # -- Same as ApioContext.required_packages 

142 self.required_packages = required_packages 

143 # -- platform: ApioPlatform 

144 self.platform = platform 

145 # -- Same as ApioContext.apio_home_dir 

146 self.apio_home_dir = apio_home_dir 

147 # -- Same as ApioContext.packages_dir 

148 self.packages_dir = packages_dir 

149 

150 # -- Sanity checks. 

151 assert isinstance(self.remote_config, RemoteConfig) 

152 assert self.required_packages 

153 assert self.platform 

154 assert self.packages_dir 

155 

156 # -- Initialized installed packages, a copy of 

157 # -- installed-packages.json. 

158 self.installed_packages: dict[str, Any] = {} 

159 

160 # -- Cache the packages index file path 

161 # -- Ex. '/home/obijuan/.apio/packages/installed_packages.json' 

162 self._packages_index_path = packages_dir / "installed_packages.json" 

163 

164 # -- Read the installed packages file, if exists. 

165 self._maybe_load_installed_packages_file() 

166 

167 def required_package_dir(self, package_name: str) -> Path: 

168 """Return the local root directory of the package with given name""" 

169 # -- Validate the package name 

170 assert package_name in self.required_packages, package_name 

171 # -- Construct the path 

172 return self.packages_dir / package_name 

173 

174 def _construct_package_download_url( 

175 self, 

176 package_remote_config: PackageRemoteConfig, 

177 ) -> str: 

178 """Construct the download URL for the given package name and 

179 version.""" 

180 

181 # -- Create vars mapping. 

182 url_vars = { 

183 "${PLATFORM}": self.platform.id, 

184 "${YYYYMMDD}": package_remote_config.release_tag.replace("-", ""), 

185 } 

186 if is_debug(1): 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 cout(f"Package URL vars: {url_vars}") 

188 

189 # -- Define the url parts. 

190 url_parts = [ 

191 "https://github.com/", 

192 package_remote_config.repo_organization, 

193 "/", 

194 package_remote_config.repo_name, 

195 "/releases/download/", 

196 package_remote_config.release_tag, 

197 "/", 

198 package_remote_config.release_file, 

199 ] 

200 

201 if is_debug(1): 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 cout(f"package url parts = {url_parts}") 

203 

204 # -- Concatenate the URL parts. 

205 url = "".join(url_parts) 

206 

207 if is_debug(1): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 cout(f"Combined package url: {url}") 

209 

210 # -- Replace placeholders with values. 

211 for name, val in url_vars.items(): 

212 url = url.replace(name, val) 

213 

214 if is_debug(1): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 cout(f"Resolved package url: {url}") 

216 

217 # -- All done. 

218 return url 

219 

220 def _download_package_file( 

221 self, url: str, dir_path: Path, package_name: str 

222 ) -> Path: 

223 """Download the given file (url). Return the path of local destination 

224 file. Exits with a user message and error code if any error. 

225 

226 * INPUTS: 

227 * url: File to download 

228 * OUTPUTS: 

229 * The path of the destination file 

230 """ 

231 

232 filepath: Path | None = None 

233 

234 try: 

235 # -- Object for downloading the file 

236 downloader = FileDownloader(url, dir_path) 

237 

238 # -- Get the destination path 

239 filepath = downloader.destination 

240 

241 downloader.download() 

242 

243 # -- If the user press Ctrl-C (Abort) 

244 except KeyboardInterrupt: 

245 

246 # -- Remove the file 

247 if filepath and filepath.is_file(): 

248 filepath.unlink() 

249 

250 # -- Inform the user 

251 fatal_error("User aborted download") 

252 

253 except IOError as exc: 

254 fatal_error( 

255 f"Failed to download package '{package_name}'", cause=exc 

256 ) 

257 

258 # -- Return the destination path 

259 return filepath 

260 

261 def _delete_package_dir(self, package_name: str, verbose: bool) -> None: 

262 """Delete the directory of the package with given name.""" 

263 package_path = self.packages_dir / package_name 

264 

265 # -- If doesn't exist, ignore silently. 

266 if not package_path.exists(): 

267 return 

268 

269 if verbose: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 cout(f"Deleting {str(package_path)}") 

271 

272 if package_path.is_dir(): 272 ↛ 277line 272 didn't jump to line 277 because the condition on line 272 was always true

273 # -- Sanity check the path and delete. 

274 assert "packages" in str(package_path).lower(), package_path 

275 shutil.rmtree(package_path) 

276 else: 

277 package_path.unlink() 

278 

279 # -- Confirm 

280 if package_path.exists(): 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true

281 fatal_error( 

282 f"Package dir deletion failed: {str(package_path.absolute())}" 

283 ) 

284 

285 def scan_and_fix_inconsistencies(self) -> bool: 

286 """Scan the packages and fix if there are errors. Returns true 

287 if the packages are installed ok.""" 

288 

289 # -- Scan the packages. 

290 scan: PackagesScanResults = self.scan_packages() 

291 

292 # -- If there are fixable errors, fix them. 

293 if scan.num_inconsistencies_to_fix() > 0: 293 ↛ 299line 293 didn't jump to line 299 because the condition on line 293 was always true

294 self._fix_inconsistencies(scan) 

295 

296 # -- Return a flag that indicates if all packages are installed ok. We 

297 # -- use a scan from before the fixing but the fixing does not touch 

298 # -- installed ok packages. 

299 return scan.packages_installed_ok() 

300 

301 def install_missing_packages_on_the_fly(self, verbose=False) -> None: 

302 """Install on the fly any missing packages. Does not print a thing if 

303 all packages are already ok. This function is intended for on demand 

304 package fetching by commands such as apio build, and thus is allowed 

305 to use fetched remote config instead of fetching a fresh one. Exists 

306 with error code if any error.""" 

307 

308 # -- Scan and fix broken package. 

309 # -- Since this is a on-the-fly operation, we don't require a fresh 

310 # -- remote config file for required packages versions. 

311 installed_ok = self.scan_and_fix_inconsistencies() 

312 

313 # -- If the packages are installed we are done, we are done. 

314 if installed_ok: 314 ↛ 325line 314 didn't jump to line 325 because the condition on line 314 was always true

315 # -- Final sanity check of the packages. 

316 self.check_packages_post_install() 

317 # -- Installed ok. 

318 return 

319 

320 # -- Here when we need to install some packages. Since we just fixed 

321 # -- we can't have broken or packages with version mismatch, just 

322 # -- installed ok, and not installed. 

323 # -- 

324 # -- Get lists of installed and required packages. 

325 installed_packages = self.installed_packages 

326 required_packages_names = self.required_packages.keys() 

327 

328 # -- Install any required package that is not installed. 

329 for package_name in required_packages_names: 

330 if package_name not in installed_packages: 

331 self.install_package( 

332 package_name=package_name, 

333 force_reinstall=False, 

334 verbose=verbose, 

335 ) 

336 

337 # -- Here all packages should be ok but we check again just in case. 

338 scan_results = self.scan_packages() 

339 if not scan_results.is_all_ok(): 

340 fatal_error( 

341 "Packages issues detected. Use " 

342 + "'apio packages list' to investigate." 

343 ) 

344 

345 # -- Final sanity check of the packages. 

346 self.check_packages_post_install() 

347 

348 def install_package( 

349 self, 

350 *, 

351 package_name: str, 

352 force_reinstall: bool, 

353 verbose: bool, 

354 ) -> None: 

355 """Install a given package. 

356 

357 'package_name' is the package name, e.g. 'examples' or 'oss-cad-suite'. 

358 'force' indicates if to perform the installation even if a matching 

359 package is already installed. 

360 'explicit' indicates that the user specified the package name(s) 

361 explicitly and thus expect more feedback in case of a 'no change' 

362 'verbose' indicates if to print extra information. 

363 

364 Returns normally if no error, exits the program with an error status 

365 and a user message if an error is detected. 

366 """ 

367 

368 # -- Force verbose if debug. 

369 if is_debug(1): 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

370 verbose = True 

371 

372 # -- Caller is responsible to check check that package name is valid 

373 # -- on this platform. 

374 assert package_name in self.required_packages, package_name 

375 

376 # -- Set up installation announcement 

377 pending_announcement: str | None = cstyle( 

378 f"Installing apio package '{package_name}'", style=EMPH3 

379 ) 

380 

381 # -- If in chatty mode, announce now and clear. Otherwise we will 

382 # -- announce later only if actually installing. 

383 if verbose and pending_announcement: 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true

384 cout(pending_announcement) 

385 pending_announcement = None 

386 

387 # -- Get package remote config from the cache. Caller can refresh the 

388 # -- cache with the latest remote config if desired. 

389 package_config: PackageRemoteConfig = ( 

390 self.remote_config.get_package_config(package_name) 

391 ) 

392 

393 # -- Get the version we should have. 

394 target_version = package_config.release_version 

395 

396 # -- If not forcing and the target version already installed then 

397 # -- nothing to do and we leave quietly. 

398 if not force_reinstall: 

399 # -- Get the package status 

400 package_status = self.classify_required_package_status( 

401 package_name 

402 ) 

403 

404 if verbose: 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true

405 cout( 

406 f"Package {package_name} status is" 

407 + f" '{package_status.value}'" 

408 ) 

409 

410 # -- If the package is OK then nothing to do. 

411 if package_status.is_ok: 

412 if verbose: 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true

413 cout( 

414 f"Package {package_name} version {target_version} " 

415 + "is already installed OK", 

416 style=SUCCESS, 

417 ) 

418 return 

419 

420 # -- Here we need to fetch and install so can be more chatty. 

421 

422 # -- Here we actually do the work. Announce if we haven't done it yet. 

423 if pending_announcement: 423 ↛ 427line 423 didn't jump to line 427 because the condition on line 423 was always true

424 cout(pending_announcement) 

425 pending_announcement = None 

426 

427 cout(f"Fetching version {target_version} ({self.platform.id})") 

428 

429 # -- Construct the download URL. 

430 download_url = self._construct_package_download_url(package_config) 

431 if verbose: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true

432 cout(f"Download URL: {download_url}") 

433 

434 # -- Prepare the packages directory. 

435 self.packages_dir.mkdir(exist_ok=True) 

436 

437 # -- Prepare the package directory. 

438 package_dir = self.packages_dir / package_name 

439 cout(f"Package dir: {package_dir}") 

440 

441 # -- Download the package file from the remote server. 

442 local_package_file = self._download_package_file( 

443 download_url, self.packages_dir, package_name 

444 ) 

445 if verbose: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true

446 cout(f"Local package file: {local_package_file}") 

447 

448 # -- Delete the old package dir, if exists, to avoid name conflicts and 

449 # -- left over files. 

450 self._delete_package_dir(package_name, verbose) 

451 

452 # -- Unpack the package. This creates a new package dir. 

453 util.unpack_tgz(local_package_file, package_dir) 

454 

455 # -- Remove the package file. We don't need it anymore. 

456 if verbose: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 cout(f"Deleting package file {local_package_file}") 

458 local_package_file.unlink() 

459 

460 # -- Add package and save. 

461 self.add_package( 

462 package_name, target_version, self.platform.id, download_url 

463 ) 

464 

465 # -- Inform the user! 

466 cout(f"Package '{package_name}' installed successfully", style=SUCCESS) 

467 

468 def _fix_inconsistencies(self, scan: PackagesScanResults) -> None: 

469 """If the package scan result contains errors, fix them. This 

470 does not install missing packages, just fixing inconsistencies.""" 

471 

472 for package_name, package_status in scan.required_packages.items(): 

473 if package_status.is_inconsistency: 

474 cout(f"Uninstalling broken package '{package_name}'") 

475 self._delete_package_dir(package_name, verbose=False) 

476 self.remove_package(package_name) 

477 

478 for orphan_name, orphan_type in scan.orphans.items(): 

479 # -- Delete an unknown entry in the installed packages index. 

480 if orphan_type == orphan_type.ORPHAN_PACKAGE: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 cout(f"Uninstalling unknown package '{orphan_name}'") 

482 self.remove_package(orphan_name) 

483 

484 # -- Delete an unknown dir in the package dir. 

485 elif orphan_type == orphan_type.ORPHAN_DIR: 485 ↛ 492line 485 didn't jump to line 492 because the condition on line 485 was always true

486 cout(f"Deleting unknown package dir '{orphan_name}'") 

487 dir_path = self.packages_dir / orphan_name 

488 assert "packages" in str(dir_path).lower(), dir_path 

489 shutil.rmtree(dir_path) 

490 

491 # -- Delete an unknown file in the packages dir. 

492 elif orphan_type == orphan_type.ORPHAN_FILE: 

493 cout(f"Deleting unknown package file '{orphan_name}'") 

494 file_path = self.packages_dir / orphan_name 

495 assert "packages" in str(file_path).lower(), dir_path 

496 file_path.unlink() 

497 

498 # -- Unexpected orphan type. 

499 else: 

500 raise ValueError(f"Unknown orphan type: {orphan_type}") 

501 

502 def read_package_build_info(self, package_name: str) -> dict[str, Any]: 

503 """Returns the BUILD-INFO.json of the package as a dict. Fatal 

504 error if doesn't exist or can't parse.""" 

505 

506 build_info_path = ( 

507 self.required_package_dir(package_name) / "BUILD-INFO.json" 

508 ) 

509 

510 # pylint: disable=broad-exception-caught 

511 

512 try: 

513 with open(build_info_path, encoding="utf-8") as f: 

514 build_info = json.load(f) 

515 except Exception as e: 

516 fatal_error( 

517 f"Reading/parsing [{build_info_path}] failed.", cause=e 

518 ) 

519 

520 return build_info 

521 

522 def get_yosys_release_tag(self) -> str: 

523 """Return the version tag (e.g. "2026-03-21") of the underlying Yosys. 

524 This value is extract from the BUILD-INFO.json file of the apio 

525 oss-cad-suite package.""" 

526 build_info = self.read_package_build_info("oss-cad-suite") 

527 return build_info["yosys-release-tag"] 

528 

529 def check_packages_post_install(self): 

530 """Called after the Apio packages were installed or fixed and are 

531 believed to be correct. Performs additional validation of the apio 

532 packages and exits with an error on any error.""" 

533 

534 # -- Read the build info of the two packages. 

535 build_info1 = self.read_package_build_info("oss-cad-suite") 

536 build_info2 = self.read_package_build_info("openxc7") 

537 

538 # -- Extract the version of the underlying yosys 

539 yosys_release_tag1 = build_info1["yosys-release-tag"] 

540 yosys_release_tag2 = build_info2["yosys-release-tag"] 

541 

542 # -- Compare the versions. 

543 if yosys_release_tag1 != yosys_release_tag2: 

544 fatal_error( 

545 'The packages "oss-cad-suite" and "openxc7" were built with ' 

546 + 'different "yosys-release-tag".', 

547 "Their respective BUILD-INFO.json files " 

548 + f'contain "{yosys_release_tag1}" vs "{yosys_release_tag2}"', 

549 info="This typically happens due to corrupt packages or " 

550 + "bad remote configuration by the Apio team.", 

551 ) 

552 

553 def classify_required_package_status( 

554 self, name: str 

555 ) -> RequiredPackageStatus: 

556 """Classify existing or missing entry under the package directory.""" 

557 

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

559 

560 # -- Check tha the package is a required one. 

561 assert name in self.required_packages, name 

562 

563 # -- Construct the package path. 

564 package_path: Path = self.required_package_dir(name) 

565 

566 if name not in self.installed_packages: 

567 return RequiredPackageStatus.PACKAGE_UNINSTALLED 

568 

569 if not package_path.exists(): 

570 return RequiredPackageStatus.PACKAGE_DIR_MISSING 

571 

572 if not package_path.is_dir(): 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true

573 return RequiredPackageStatus.PACKAGE_DIR_IS_A_FILE 

574 

575 # -- Get installed package info or "" if not installed. 

576 ( 

577 installed_version, 

578 installed_platform_id, 

579 installed_platform_version, 

580 installed_src_url, 

581 ) = self.get_installed_package_info(name) 

582 

583 # -- Get the package's remote config 

584 package_config: PackageRemoteConfig = ( 

585 self.remote_config.get_package_config(name) 

586 ) 

587 

588 if ( 588 ↛ 592line 588 didn't jump to line 592 because the condition on line 588 was never true

589 not installed_version 

590 or installed_version != package_config.release_version 

591 ): 

592 return RequiredPackageStatus.PACKAGE_VERSION_MISMATCH 

593 

594 if ( 594 ↛ 598line 594 didn't jump to line 598 because the condition on line 594 was never true

595 not installed_platform_id 

596 or installed_platform_id != self.platform.id 

597 ): 

598 return RequiredPackageStatus.PACKAGE_PLATFORM_MISMATCH 

599 

600 if installed_platform_version != util.get_apio_version_str(): 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

601 return RequiredPackageStatus.PACKAGE_APIO_VERSION_MISMATCH 

602 

603 true_src_url = self._construct_package_download_url(package_config) 

604 

605 if installed_src_url != true_src_url: 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true

606 return RequiredPackageStatus.PACKAGE_URL_MISMATCH 

607 

608 return RequiredPackageStatus.PACKAGE_OK 

609 

610 def scan_packages(self) -> PackagesScanResults: 

611 """Scans the available and installed packages and returns 

612 the findings as a PackageScanResults object.""" 

613 

614 result = PackagesScanResults({}, {}) 

615 

616 # -- Scan the required packages. 

617 for package_name in self.required_packages: 

618 package_status: RequiredPackageStatus = ( 

619 self.classify_required_package_status(package_name) 

620 ) 

621 result.required_packages[package_name] = package_status 

622 

623 # -- Scan the installed packages and identify orphan packages. 

624 for package_name in self.installed_packages: 

625 if package_name not in self.required_packages: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true

626 result.orphans[package_name] = OrphanType.ORPHAN_PACKAGE 

627 

628 # -- Scan the packages directory and identify orphan dirs and files. 

629 for path in self.packages_dir.glob("*"): 

630 base_name = os.path.basename(path) 

631 assert isinstance(base_name, str), type(base_name) 

632 

633 # -- Ignore the installed packages index file. 

634 if base_name == "installed_packages.json": 

635 continue 

636 

637 # -- I the dir entry is of a required or orphan package, skip it, 

638 # -- it will be covered by the package handling. 

639 if ( 

640 base_name in result.required_packages 

641 or base_name in result.orphans 

642 ): 

643 continue 

644 

645 # -- Classify the orphan as a dir or file. 

646 if path.is_dir(): 646 ↛ 649line 646 didn't jump to line 649 because the condition on line 646 was always true

647 result.orphans[base_name] = OrphanType.ORPHAN_DIR 

648 else: 

649 result.orphans[base_name] = OrphanType.ORPHAN_FILE 

650 

651 # -- All done 

652 return result 

653 

654 def _maybe_load_installed_packages_file(self): 

655 """Load the installed packages index file if exists, e.g. 

656 ~/.apio/packages/installed_packages.json, populates 

657 self.installed_packages with the data read. 

658 """ 

659 

660 # -- Do nothing if the file doesn't exist. 

661 if not self._packages_index_path.exists(): 

662 return 

663 

664 # -- Read the file as a json dict. Handle invalid content 

665 # -- gracefully, since this runs on every apio command. 

666 try: 

667 with open(self._packages_index_path, "r", encoding="utf8") as f: 

668 self.installed_packages = json.load(f) 

669 

670 # -- Perform a shallow sanity check. 

671 # -- TODO: Do a full json validation. 

672 assert isinstance( 

673 self.installed_packages, dict 

674 ), "Install packages not a dict" 

675 for name, info in self.installed_packages.items(): 

676 assert isinstance( 

677 info, dict 

678 ), f"installed package '{name}' not a dict" 

679 

680 except (OSError, ValueError, AssertionError) as e: 

681 fatal_error( 

682 "Invalid installed packages index file " 

683 + f"{self._packages_index_path}", 

684 cause=e, 

685 info="You can delete the file, " 

686 + "Apio will recreate it automatically.", 

687 ) 

688 

689 def _save_installed_packages(self): 

690 """Save the installed packages file""" 

691 

692 # -- Create the enclosing folder, if it does not exist yet 

693 parent = self._packages_index_path.parent 

694 if not parent.exists(): 694 ↛ 695line 694 didn't jump to line 695 because the condition on line 694 was never true

695 parent.mkdir() 

696 

697 # -- Write to installed packages file. 

698 with open(self._packages_index_path, "w", encoding="utf8") as f: 

699 json.dump(self.installed_packages, f, indent=4) 

700 

701 # -- Dump for debugging. 

702 if is_debug(1): 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true

703 cout("Saved installed packages index:", style=EMPH3) 

704 cout(json.dumps(self.installed_packages, indent=2)) 

705 

706 def get_installed_package_version(self, package_name: str) -> str: 

707 """Return the version of the given installed package. Fatal error 

708 if the package is not installed have its info is corrupt. 

709 """ 

710 package_info = self.installed_packages.get(package_name) 

711 assert package_info is not None, package_name 

712 package_version = package_info.get("version") 

713 assert package_version is not None 

714 return package_version 

715 

716 def get_installed_package_info( 

717 self, package_name: str 

718 ) -> tuple[str, str, str, str]: 

719 """Return (package_version, platform_id) of the given installed 

720 package. Values are replaced with "" if not installed or a value is 

721 missing.""" 

722 package_info = self.installed_packages.get(package_name, {}) 

723 package_version = package_info.get("version", "") 

724 platform_id = package_info.get("platform", "") 

725 platform_version = package_info.get("loaded-by", "") 

726 package_source_url = package_info.get("loaded-from", "") 

727 return ( 

728 package_version, 

729 platform_id, 

730 platform_version, 

731 package_source_url, 

732 ) 

733 

734 def add_package(self, name: str, version: str, platform_id: str, url: str): 

735 """Add a package to the installed packages and save.""" 

736 

737 # -- Updated the installed package data. 

738 self.installed_packages[name] = { 

739 "version": version, 

740 "platform": platform_id, 

741 "loaded-by": util.get_apio_version_str(), 

742 "loaded-at": get_datetime_stamp(), 

743 "loaded-from": url, 

744 } 

745 # self._save() 

746 self._save_installed_packages() 

747 

748 def remove_package(self, name: str): 

749 """Remove a package from the installed packages file. Do nothing 

750 if not in installed packages.""" 

751 

752 if name in self.installed_packages.keys(): 752 ↛ exitline 752 didn't return from function 'remove_package' because the condition on line 752 was always true

753 del self.installed_packages[name] 

754 # self._save() 

755 self._save_installed_packages() 

756 

757 def get_required_package_spec(self, package_name: str) -> dict: 

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

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

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

761 """ 

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

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

764 fatal_error(f"Unknown package '{package_name}'") 

765 

766 return package_info