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

316 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"""Package install/uninstall functionality. 

10Used by the 'apio packages' command. 

11""" 

12 

13import os 

14import json 

15from enum import Enum, unique 

16from datetime import datetime 

17from dataclasses import dataclass 

18from typing import Any 

19from pathlib import Path 

20import shutil 

21import tarfile 

22from rich.progress import track 

23from apio.common.apio_console import cout, cstyle, console, fatal_error 

24from apio.common.apio_styles import SUCCESS, EMPH3 

25from apio.common.debug_util import is_debug 

26from apio.managers.downloader import FileDownloader 

27from apio.utils import util 

28from apio.common.apio_platforms import ApioPlatform 

29from apio.managers.remote_config import RemoteConfig 

30from apio.common.proto.stubs.apio_config_pb2 import ApioPackageSpec 

31from apio.common.proto.stubs.apio_remote_config_pb2 import ( 

32 RemoteConfigPackageSpec, 

33) 

34 

35 

36@unique 

37class RequiredPackageStatus(Enum): 

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

39 

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

41 # -- 'apio packages list' command. 

42 

43 PACKAGE_UNINSTALLED = "Uninstalled" 

44 PACKAGE_DIR_MISSING = "Package dir missing" 

45 PACKAGE_DIR_IS_A_FILE = "Package dir is a file" 

46 PACKAGE_VERSION_MISMATCH = "Version mismatch" 

47 PACKAGE_PLATFORM_MISMATCH = "Platform mismatch" 

48 PACKAGE_APIO_VERSION_MISMATCH = "Apio version mismatch" 

49 PACKAGE_URL_MISMATCH = "Source URL mismatch" 

50 PACKAGE_OK = "OK" 

51 

52 @property 

53 def is_ok(self) -> bool: 

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

55 return self == self.PACKAGE_OK 

56 

57 @property 

58 def is_inconsistency(self) -> bool: 

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

60 uninstalled packages.""" 

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

62 

63 

64@unique 

65class OrphanType(Enum): 

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

67 

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

69 # -- 'apio packages list' command. 

70 

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

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

73 ORPHAN_PACKAGE = "Orphan package" 

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

75 ORPHAN_FILE = "Orphan file" 

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

77 # -- package. 

78 ORPHAN_DIR = "Orphan dir" 

79 

80 

81@dataclass 

82class PackagesScanResults: 

83 """Represents results of packages scan.""" 

84 

85 # -- Names and statuses of required packages. 

86 platform_packages: dict[str, RequiredPackageStatus] 

87 

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

89 orphans: dict[str, OrphanType] 

90 

91 def packages_installed_ok(self) -> bool: 

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

93 regardless of other fixable errors.""" 

94 return all(status.is_ok for status in self.platform_packages.values()) 

95 

96 def num_inconsistencies_to_fix(self) -> int: 

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

98 installing any missing package.""" 

99 required_packages_inconsistencies = sum( 

100 1 

101 for status in self.platform_packages.values() 

102 if status.is_inconsistency 

103 ) 

104 # -- All orphan errors are considered to be inconsistencies. 

105 orphans_errors = len(self.orphans) 

106 return required_packages_inconsistencies + orphans_errors 

107 

108 def is_all_ok(self) -> bool: 

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

110 issues.""" 

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

112 

113 def dump(self): 

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

115 cout() 

116 cout("Package scan results:") 

117 cout(f" required {self.platform_packages}") 

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

119 

120 

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

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

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

124 dt = datetime.now() 

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

126 

127 

128def unpack_tgz(archive_file_path: Path, dest_dir: Path) -> None: 

129 """Unpack a .tgz archive into the given dest directory. Exits with an 

130 error if an archive item would be written outside of dest_dir, e.g. 

131 '../foo', '/foo', or a link to outside of dest_dir.""" 

132 

133 # -- Get the file name 

134 archive_name = archive_file_path.name 

135 

136 # -- Check it's a ".tgz" file 

137 if not archive_name.endswith(".tgz"): 

138 fatal_error(f"Cannot unarchive'{archive_name}', it's not a .tgz file.") 

139 

140 # -- We use the tarfile 'data' filter which rejects items that would be 

141 # -- written outside of the dest dir. Search 'extraction_filter' in 

142 # -- https://docs.python.org/3/library/tarfile.html 

143 

144 # -- Extract all items while animating the progress bar. 

145 with tarfile.open(archive_file_path) as tar_file: 

146 

147 items = tar_file.getmembers() 

148 

149 # -- Check all the items before extracting any of them. This way an 

150 # -- unsafe archive is rejected before anything is written, and the 

151 # -- error is not lost in the progress bar output. 

152 for item in items: 

153 # -- Pre-check that we don't write outside dest_dir, e.g. with 

154 # -- ".." path in the tar file. 

155 try: 

156 tarfile.data_filter(item, str(dest_dir)) 

157 except tarfile.FilterError as e: 

158 fatal_error( 

159 f"Unsafe item '{item.name}' in archive '{archive_name}'.", 

160 cause=e, 

161 ) 

162 

163 for i in track( 

164 range(len(items)), 

165 description="Unpacking ", 

166 console=console(), 

167 ): 

168 item = items[i] 

169 # -- Extract the item. The filter checks again, now that the 

170 # -- previous items are on disk, e.g. 'a/../x' after the link 

171 # -- 'a -> .'. 

172 try: 

173 tar_file.extract(item, dest_dir, filter="data") 

174 except tarfile.FilterError as e: 

175 fatal_error( 

176 f"Unsafe item '{item.name}' in archive '{archive_name}'.", 

177 cause=e, 

178 ) 

179 

180 

181class PackageManager: 

182 """Context for package managements operations. 

183 This class provides the information needed for package management 

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

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

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

187 the ApioContext object is fully initialized. 

188 """ 

189 

190 def __init__( 

191 self, 

192 remote_config: RemoteConfig, 

193 platform_packages: dict[str, ApioPackageSpec], 

194 platform: ApioPlatform, 

195 apio_home_dir: Path, 

196 packages_dir: Path, 

197 ): 

198 

199 # pylint: disable=too-many-arguments 

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

201 

202 # -- Same as ApioContext.remote_config 

203 self.remote_config = remote_config 

204 # -- Same as ApioContext.platform_packages 

205 self.platform_packages = platform_packages 

206 # -- platform: ApioPlatform 

207 self.platform = platform 

208 # -- Same as ApioContext.apio_home_dir 

209 self.apio_home_dir = apio_home_dir 

210 # -- Same as ApioContext.packages_dir 

211 self.packages_dir = packages_dir 

212 

213 # -- Sanity checks. 

214 assert isinstance(self.remote_config, RemoteConfig) 

215 assert self.platform_packages 

216 assert self.platform 

217 assert self.packages_dir 

218 

219 # -- Initialized installed packages, a copy of 

220 # -- installed-packages.json. 

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

222 

223 # -- Cache the packages index file path 

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

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

226 

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

228 self._maybe_load_installed_packages_file() 

229 

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

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

232 # -- Validate the package name 

233 assert package_name in self.platform_packages, package_name 

234 # -- Construct the path 

235 return self.packages_dir / package_name 

236 

237 def _construct_package_download_url( 

238 self, 

239 package_remote_config: RemoteConfigPackageSpec, 

240 ) -> str: 

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

242 version.""" 

243 

244 # -- Create vars mapping. 

245 url_vars = { 

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

247 "${YYYYMMDD}": package_remote_config.release.tag.replace("-", ""), 

248 } 

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

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

251 

252 # -- Define the url parts. 

253 url_parts = [ 

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

255 package_remote_config.repository.organization, 

256 "/", 

257 package_remote_config.repository.name, 

258 "/releases/download/", 

259 package_remote_config.release.tag, 

260 "/", 

261 package_remote_config.release.package, 

262 ] 

263 

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

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

266 

267 # -- Concatenate the URL parts. 

268 url = "".join(url_parts) 

269 

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

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

272 

273 # -- Replace placeholders with values. 

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

275 url = url.replace(name, val) 

276 

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

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

279 

280 # -- All done. 

281 return url 

282 

283 def _download_package_file( 

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

285 ) -> Path: 

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

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

288 

289 * INPUTS: 

290 * url: File to download 

291 * OUTPUTS: 

292 * The path of the destination file 

293 """ 

294 

295 filepath: Path | None = None 

296 

297 try: 

298 # -- Object for downloading the file 

299 downloader = FileDownloader(url, dir_path) 

300 

301 # -- Get the destination path 

302 filepath = downloader.destination 

303 

304 downloader.download() 

305 

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

307 except KeyboardInterrupt: 

308 

309 # -- Remove the file 

310 if filepath and filepath.is_file(): 

311 filepath.unlink() 

312 

313 # -- Inform the user 

314 fatal_error("User aborted download") 

315 

316 except IOError as exc: 

317 fatal_error( 

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

319 ) 

320 

321 # -- Return the destination path 

322 return filepath 

323 

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

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

326 package_path = self.packages_dir / package_name 

327 

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

329 if not package_path.exists(): 

330 return 

331 

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

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

334 

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

336 # -- Sanity check the path and delete. 

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

338 shutil.rmtree(package_path) 

339 else: 

340 package_path.unlink() 

341 

342 # -- Confirm 

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

344 fatal_error( 

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

346 ) 

347 

348 def scan_and_fix_inconsistencies(self) -> bool: 

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

350 if the packages are installed ok.""" 

351 

352 # -- Scan the packages. 

353 scan: PackagesScanResults = self.scan_packages() 

354 

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

356 if scan.num_inconsistencies_to_fix() > 0: 

357 self._fix_inconsistencies(scan) 

358 

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

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

361 # -- installed ok packages. 

362 return scan.packages_installed_ok() 

363 

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

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

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

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

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

369 with error code if any error.""" 

370 

371 # -- Scan and fix broken package. 

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

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

374 installed_ok = self.scan_and_fix_inconsistencies() 

375 

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

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

378 # -- Final sanity check of the packages. 

379 self.check_packages_post_install() 

380 # -- Installed ok. 

381 return 

382 

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

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

385 # -- installed ok, and not installed. 

386 # -- 

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

388 installed_packages = self.installed_packages 

389 platform_packages_names = self.platform_packages.keys() 

390 

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

392 for package_name in platform_packages_names: 

393 if package_name not in installed_packages: 

394 self.install_package( 

395 package_name=package_name, 

396 force_reinstall=False, 

397 verbose=verbose, 

398 ) 

399 

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

401 scan_results = self.scan_packages() 

402 if not scan_results.is_all_ok(): 

403 fatal_error( 

404 "Packages issues detected. Use " 

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

406 ) 

407 

408 # -- Final sanity check of the packages. 

409 self.check_packages_post_install() 

410 

411 def install_package( 

412 self, 

413 *, 

414 package_name: str, 

415 force_reinstall: bool, 

416 verbose: bool, 

417 ) -> None: 

418 """Install a given package. 

419 

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

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

422 package is already installed. 

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

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

425 'verbose' indicates if to print extra information. 

426 

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

428 and a user message if an error is detected. 

429 """ 

430 

431 # -- Force verbose if debug. 

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

433 verbose = True 

434 

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

436 # -- on this platform. 

437 assert package_name in self.platform_packages, package_name 

438 

439 # -- Set up installation announcement 

440 pending_announcement: str | None = cstyle( 

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

442 ) 

443 

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

445 # -- announce later only if actually installing. 

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

447 cout(pending_announcement) 

448 pending_announcement = None 

449 

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

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

452 package_config: RemoteConfigPackageSpec = ( 

453 self.remote_config.get_package_config(package_name) 

454 ) 

455 

456 # -- Get the version we should have. 

457 target_version = package_config.release.tag.replace("-", ".") 

458 

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

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

461 if not force_reinstall: 

462 # -- Get the package status 

463 package_status = self.classify_required_package_status( 

464 package_name 

465 ) 

466 

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

468 cout( 

469 f"Package {package_name} status is" 

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

471 ) 

472 

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

474 if package_status.is_ok: 

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

476 cout( 

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

478 + "is already installed OK", 

479 style=SUCCESS, 

480 ) 

481 return 

482 

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

484 

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

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

487 cout(pending_announcement) 

488 pending_announcement = None 

489 

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

491 

492 # -- Construct the download URL. 

493 download_url = self._construct_package_download_url(package_config) 

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

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

496 

497 # -- Prepare the packages directory. 

498 self.packages_dir.mkdir(exist_ok=True) 

499 

500 # -- Prepare the package directory. 

501 package_dir = self.packages_dir / package_name 

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

503 

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

505 local_package_file = self._download_package_file( 

506 download_url, self.packages_dir, package_name 

507 ) 

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

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

510 

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

512 # -- left over files. 

513 self._delete_package_dir(package_name, verbose) 

514 

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

516 unpack_tgz(local_package_file, package_dir) 

517 

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

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

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

521 local_package_file.unlink() 

522 

523 # -- Add package and save. 

524 self.add_package( 

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

526 ) 

527 

528 # -- Inform the user! 

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

530 

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

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

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

534 

535 for package_name, package_status in scan.platform_packages.items(): 

536 if package_status.is_inconsistency: 

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

538 self._delete_package_dir(package_name, verbose=False) 

539 self.remove_package(package_name) 

540 

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

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

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

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

545 self.remove_package(orphan_name) 

546 

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

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

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

550 dir_path = self.packages_dir / orphan_name 

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

552 shutil.rmtree(dir_path) 

553 

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

555 elif orphan_type == orphan_type.ORPHAN_FILE: 

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

557 file_path = self.packages_dir / orphan_name 

558 assert "packages" in str(file_path).lower(), file_path 

559 file_path.unlink() 

560 

561 # -- Unexpected orphan type. 

562 else: 

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

564 

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

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

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

568 

569 build_info_path = ( 

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

571 ) 

572 

573 # pylint: disable=broad-exception-caught 

574 

575 try: 

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

577 build_info = json.load(f) 

578 except Exception as e: 

579 fatal_error( 

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

581 ) 

582 

583 return build_info 

584 

585 def get_yosys_release_tag(self) -> str: 

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

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

588 oss-cad-suite package.""" 

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

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

591 

592 def check_packages_post_install(self): 

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

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

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

596 

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

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

599 build_info2 = self.read_package_build_info("openxc7") 

600 

601 # -- Extract the version of the underlying yosys 

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

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

604 

605 # -- Compare the versions. 

606 if yosys_release_tag1 != yosys_release_tag2: 

607 fatal_error( 

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

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

610 "Their respective BUILD-INFO.json files " 

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

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

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

614 ) 

615 

616 def classify_required_package_status( 

617 self, name: str 

618 ) -> RequiredPackageStatus: 

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

620 

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

622 

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

624 assert name in self.platform_packages, name 

625 

626 # -- Construct the package path. 

627 package_path: Path = self.required_package_dir(name) 

628 

629 if name not in self.installed_packages: 

630 return RequiredPackageStatus.PACKAGE_UNINSTALLED 

631 

632 if not package_path.exists(): 

633 return RequiredPackageStatus.PACKAGE_DIR_MISSING 

634 

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

636 return RequiredPackageStatus.PACKAGE_DIR_IS_A_FILE 

637 

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

639 ( 

640 installed_version, 

641 installed_platform_id, 

642 installed_platform_version, 

643 installed_src_url, 

644 ) = self.get_installed_package_info(name) 

645 

646 # -- Get the package's remote config 

647 package_config: RemoteConfigPackageSpec = ( 

648 self.remote_config.get_package_config(name) 

649 ) 

650 

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

652 not installed_version 

653 or installed_version 

654 != package_config.release.tag.replace("-", ".") 

655 ): 

656 return RequiredPackageStatus.PACKAGE_VERSION_MISMATCH 

657 

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

659 not installed_platform_id 

660 or installed_platform_id != self.platform.id 

661 ): 

662 return RequiredPackageStatus.PACKAGE_PLATFORM_MISMATCH 

663 

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

665 return RequiredPackageStatus.PACKAGE_APIO_VERSION_MISMATCH 

666 

667 true_src_url = self._construct_package_download_url(package_config) 

668 

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

670 return RequiredPackageStatus.PACKAGE_URL_MISMATCH 

671 

672 return RequiredPackageStatus.PACKAGE_OK 

673 

674 def scan_packages(self) -> PackagesScanResults: 

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

676 the findings as a PackageScanResults object.""" 

677 

678 result = PackagesScanResults({}, {}) 

679 

680 # -- Scan the required packages. 

681 for package_name in self.platform_packages: 

682 package_status: RequiredPackageStatus = ( 

683 self.classify_required_package_status(package_name) 

684 ) 

685 result.platform_packages[package_name] = package_status 

686 

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

688 for package_name in self.installed_packages: 

689 if package_name not in self.platform_packages: 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true

690 result.orphans[package_name] = OrphanType.ORPHAN_PACKAGE 

691 

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

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

694 base_name = os.path.basename(path) 

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

696 

697 # -- Ignore the installed packages index file. 

698 if base_name == "installed_packages.json": 

699 continue 

700 

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

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

703 if ( 

704 base_name in result.platform_packages 

705 or base_name in result.orphans 

706 ): 

707 continue 

708 

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

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

711 result.orphans[base_name] = OrphanType.ORPHAN_DIR 

712 else: 

713 result.orphans[base_name] = OrphanType.ORPHAN_FILE 

714 

715 # -- All done 

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

717 result.dump() 

718 

719 return result 

720 

721 def _maybe_load_installed_packages_file(self): 

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

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

724 self.installed_packages with the data read. 

725 """ 

726 

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

728 if not self._packages_index_path.exists(): 

729 return 

730 

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

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

733 try: 

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

735 self.installed_packages = json.load(f) 

736 

737 # -- Perform a shallow sanity check. 

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

739 assert isinstance( 

740 self.installed_packages, dict 

741 ), "Install packages not a dict" 

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

743 assert isinstance( 

744 info, dict 

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

746 

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

748 fatal_error( 

749 "Invalid installed packages index file " 

750 + f"{self._packages_index_path}", 

751 cause=e, 

752 info="You can delete the file, " 

753 + "Apio will recreate it automatically.", 

754 ) 

755 

756 def _save_installed_packages(self): 

757 """Save the installed packages file""" 

758 

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

760 parent = self._packages_index_path.parent 

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

762 parent.mkdir() 

763 

764 # -- Write to installed packages file. 

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

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

767 

768 # -- Dump for debugging. 

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

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

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

772 

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

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

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

776 """ 

777 package_info = self.installed_packages.get(package_name) 

778 assert package_info is not None, package_name 

779 package_version = package_info.get("version") 

780 assert package_version is not None 

781 return package_version 

782 

783 def get_installed_package_info( 

784 self, package_name: str 

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

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

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

788 missing.""" 

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

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

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

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

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

794 return ( 

795 package_version, 

796 platform_id, 

797 platform_version, 

798 package_source_url, 

799 ) 

800 

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

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

803 

804 # -- Updated the installed package data. 

805 self.installed_packages[name] = { 

806 "version": version, 

807 "platform": platform_id, 

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

809 "loaded-at": get_datetime_stamp(), 

810 "loaded-from": url, 

811 } 

812 # self._save() 

813 self._save_installed_packages() 

814 

815 def remove_package(self, name: str): 

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

817 if not in installed packages.""" 

818 

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

820 del self.installed_packages[name] 

821 # self._save() 

822 self._save_installed_packages() 

823 

824 def get_required_package_spec(self, package_name: str) -> ApioPackageSpec: 

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

826 The information is a proto buffer originated at packages.json(). 

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

828 """ 

829 package_info = self.platform_packages.get(package_name, None) 

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

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

832 

833 return package_info