Coverage for apio / profile.py: 75%

239 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-24 01:53 +0000

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

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

3# -- (C) 2016-2019 FPGAwars 

4# -- Author Jesús Arroyo 

5# -- License GPLv2 

6"""Manage the apio profile file""" 

7 

8import json 

9import sys 

10from enum import Enum 

11from dataclasses import dataclass 

12from datetime import datetime 

13from typing import Dict, Optional, Any, List, Tuple 

14from pathlib import Path 

15import requests 

16from jsonschema import validate 

17from jsonschema.exceptions import ValidationError 

18from apio.common import apio_console 

19from apio.common.apio_console import cout 

20from apio.common.apio_styles import INFO, EMPH3, ERROR 

21from apio.utils import util, jsonc 

22 

23# -- JSON schema for validating a remote config file. 

24REMOTE_CONFIG_SCHEMA = { 

25 "$schema": "https://json-schema.org/draft/2020-12/schema", 

26 "type": "object", 

27 "required": ["packages"], 

28 "properties": { 

29 # -- Packages 

30 "packages": { 

31 "type": "object", 

32 "patternProperties": { 

33 "^.*$": { 

34 "type": "object", 

35 "required": ["repository", "release"], 

36 "properties": { 

37 # -- Repository 

38 "repository": { 

39 "type": "object", 

40 "required": ["organization", "name"], 

41 "properties": { 

42 # -- Repo organization. e.g. "fpgawars" 

43 "organization": {"type": "string"}, 

44 # -- Repo name, e.g. 'examples' 

45 "name": {"type": "string"}, 

46 }, 

47 "additionalProperties": False, 

48 }, 

49 # -- Release. 

50 "release": { 

51 "type": "object", 

52 "required": [ 

53 "tag", 

54 "package", 

55 ], 

56 "properties": { 

57 # -- Tag 

58 "tag": { 

59 "type": "string", 

60 "pattern": r"^\d{4}\-\d{2}\-\d{2}$", 

61 }, 

62 # -- Package 

63 "package": {"type": "string"}, 

64 }, 

65 "additionalProperties": False, 

66 }, 

67 }, 

68 "additionalProperties": False, 

69 } 

70 }, 

71 "additionalProperties": False, 

72 } 

73 }, 

74 "additionalProperties": False, 

75} 

76 

77 

78class RemoteConfigPolicy(Enum): 

79 """Represents possible requirements from the remote config.""" 

80 

81 # -- Config is being used but can be a cached value, as long that it's 

82 # -- not too old. 

83 CACHED_OK = 1 

84 # -- Config is being used and a fresh copy is that was fetch in this 

85 # -- invocation of Apio is required. 

86 GET_FRESH = 2 

87 

88 

89@dataclass(frozen=True) 

90class PackageRemoteConfig: 

91 """Contains a package info from the remote config.""" 

92 

93 # -- E.g. "tools-oss-cad-suite" 

94 repo_name: str 

95 # -- E.g. "FPGAwars" 

96 repo_organization: str 

97 # -- E.g. "0.2.3" 

98 release_version: str 

99 # -- E.g. "${YYYY-MM-DD}"" 

100 release_tag: str 

101 # -- E.g. "apio-oss-cad-suite-${PLATFORM}-${YYYYMMDD}.zip" 

102 release_file: str 

103 

104 

105def get_datetime_stamp(dt: Optional[datetime] = None) -> str: 

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

107 if dt is None: 

108 dt = datetime.now() 

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

110 

111 

112def days_between_datetime_stamps( 

113 ts1: str, ts2: str, default: Any 

114) -> Optional[int]: 

115 """Given two values generated by get_datetime_stamp(), return the 

116 number of days from ts1 to ts2. The value can be negative if ts2 is 

117 earlier than ts1. Returns the given 'default' value if either timestamp 

118 is invalid.""" 

119 

120 # -- The parsing format. 

121 fmt = "%Y-%m-%d-%H-%M" 

122 

123 # -- Convert to timedates 

124 try: 

125 datetime1 = datetime.strptime(ts1, fmt) 

126 datetime2 = datetime.strptime(ts2, fmt) 

127 except ValueError: 

128 return default 

129 

130 # -- Round to beginning of day. 

131 day1 = datetime(datetime1.year, datetime1.month, datetime1.day) 

132 day2 = datetime(datetime2.year, datetime2.month, datetime2.day) 

133 

134 # -- Compute the diff in days. 

135 delta_days: int = (day2 - day1).days 

136 

137 # -- All done. 

138 assert isinstance(delta_days, int) 

139 return delta_days 

140 

141 

142def minutes_between_datetime_stamps( 

143 ts1: str, ts2: str, default: Any 

144) -> Optional[int]: 

145 """Return the number of minutes from ts1 to ts2 or default if any of 

146 the timestamps is invalid.""" 

147 

148 # -- The parsing format. 

149 fmt = "%Y-%m-%d-%H-%M" 

150 

151 # -- Convert to timedates 

152 try: 

153 datetime1 = datetime.strptime(ts1, fmt) 

154 datetime2 = datetime.strptime(ts2, fmt) 

155 except ValueError: 

156 return default 

157 

158 # -- Calculate the diff in minutes. 

159 delta_minutes = int((datetime2 - datetime1).total_seconds() / 60) 

160 assert isinstance(delta_minutes, int), type(delta_minutes) 

161 return delta_minutes 

162 

163 

164class Profile: 

165 """Class for managing the apio profile file 

166 ex. ~/.apio/profile.json 

167 """ 

168 

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

170 

171 # -- Only these instance vars are allowed. 

172 __slots__ = ( 

173 "_profile_path", 

174 "_packages_index_path", 

175 "remote_config_url", 

176 "remote_config_ttl_days", 

177 "remote_config_retry_minutes", 

178 "_remote_config_policy", 

179 "_cached_remote_config", 

180 "preferences", 

181 "installed_packages", 

182 ) 

183 

184 def __init__( 

185 self, 

186 home_dir: Path, 

187 packages_dir: Path, 

188 remote_config_url_template: str, 

189 remote_config_ttl_days: int, 

190 remote_config_retry_minutes: int, 

191 remote_config_policy: RemoteConfigPolicy, 

192 ): 

193 """remote_config_url_template is a url string with the 

194 placeholder {major} and {minor} for the apio's major and minor 

195 version. '""" 

196 

197 # pylint: disable=too-many-arguments 

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

199 

200 # -- Sanity check 

201 assert isinstance(remote_config_ttl_days, int) 

202 assert 0 < remote_config_ttl_days <= 30 

203 

204 # -- Sanity check 

205 assert isinstance(remote_config_retry_minutes, int) 

206 assert 0 < remote_config_retry_minutes <= (60 * 24) 

207 

208 # -- Resolve and cache the remote config url. Replaced the placeholders 

209 # -- with the major and minor versions of apio. Path version is 

210 # -- not used. 

211 ver_tuple = util.get_apio_version_tuple() 

212 url = remote_config_url_template 

213 url = url.replace("{major}", str(ver_tuple[0])) 

214 url = url.replace("{minor}", str(ver_tuple[1])) 

215 self.remote_config_url = url 

216 

217 # -- Save remote url ttl setting. 

218 self.remote_config_ttl_days = remote_config_ttl_days 

219 

220 # -- Save the remote config fetch retry minutes. 

221 self.remote_config_retry_minutes = remote_config_retry_minutes 

222 

223 # -- Save remote config policy. 

224 self._remote_config_policy = remote_config_policy 

225 

226 # -- Verify that we resolved all the placeholders. 

227 assert "{" not in self.remote_config_url, self.remote_config_url 

228 

229 if util.is_debug(1): 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 cout(f"Remote config url: {self.remote_config_url}") 

231 

232 # ---- Set the default parameters 

233 

234 # User preferences 

235 self.preferences = {} 

236 

237 # -- Installed package versions 

238 self.installed_packages = {} 

239 

240 # -- A copy of remote config. 

241 self._cached_remote_config = {} 

242 

243 # -- Cache the profile file path 

244 # -- Ex. '/home/obijuan/.apio/profile.json' 

245 self._profile_path = home_dir / "profile.json" 

246 

247 # -- Cache the packages index file path 

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

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

250 

251 # -- Read the profile from file, if exists. 

252 self._load_profile_file() 

253 

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

255 self._load_installed_packages_file() 

256 

257 # -- Apply config policy 

258 self._apply_remote_config_policy() 

259 

260 def _apply_remote_config_policy(self) -> None: 

261 """Called after loading the profile file, to apply the remote config 

262 policy for this invocation.""" 

263 

264 # -- Case 1: A fresh config is required for the current command. 

265 if self._remote_config_policy == RemoteConfigPolicy.GET_FRESH: 

266 self._fetch_and_update_remote_config(error_is_fatal=True) 

267 return 

268 

269 # -- Case 2: A fresh config is optional but there is no cached 

270 # -- config so practically it's required. 

271 assert self._remote_config_policy == RemoteConfigPolicy.CACHED_OK 

272 if not self._cached_remote_config: 

273 if util.is_debug(1): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 cout("Saved remote config is not available.", style=INFO) 

275 self._fetch_and_update_remote_config(error_is_fatal=True) 

276 return 

277 

278 # -- Case 3: May need to fetch a new config but can continue with 

279 # -- the cached config in case of a fetch failure. 

280 # 

281 # -- Get the cached config metadata. 

282 cashed_config_metadata = self._cached_remote_config.get("metadata", {}) 

283 last_fetch_timestamp = cashed_config_metadata.get("loaded-at", "") 

284 last_fetch_url = cashed_config_metadata.get("loaded-from", "") 

285 

286 # -- Determine if we need a new config because the remote config URL 

287 # -- was changed (e.g. with APIO_REMOTE_CONFIG_URL) 

288 url_changed = last_fetch_url != self.remote_config_url 

289 

290 # -- Determine if there is time related reason to fetch a new config. 

291 datetime_stamp_now = get_datetime_stamp() 

292 days_since_last_fetch = days_between_datetime_stamps( 

293 last_fetch_timestamp, datetime_stamp_now, default=99999 

294 ) 

295 time_valid = 0 <= days_since_last_fetch < self.remote_config_ttl_days 

296 

297 # -- Determine if we already tried recently to refresh this config and 

298 # -- failed. 

299 refresh_failure_timestamp = cashed_config_metadata.get( 

300 "refresh-failure-on", "" 

301 ) 

302 minutes_since_refresh_failure = minutes_between_datetime_stamps( 

303 refresh_failure_timestamp, datetime_stamp_now, default=99999 

304 ) 

305 refresh_failed_recently = ( 

306 0 

307 <= minutes_since_refresh_failure 

308 < self.remote_config_retry_minutes 

309 ) 

310 

311 # -- Dump info for debugging. 

312 if util.is_debug(1): 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 cout( 

314 f"{days_since_last_fetch=}, {time_valid=}, {url_changed=}", 

315 f"{minutes_since_refresh_failure=}, " 

316 f"{refresh_failed_recently=}", 

317 style=EMPH3, 

318 ) 

319 

320 # -- Fetch the new config if needed. 

321 if url_changed or not time_valid: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 if not refresh_failed_recently: 

323 self._fetch_and_update_remote_config(error_is_fatal=False) 

324 

325 @property 

326 def remote_config(self) -> Dict: 

327 """Returns the remote config that is applicable for this invocation. 

328 Should not called if the context was initialized with NO_CONFIG.""" 

329 return self._cached_remote_config 

330 

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

332 """Add a package to the profile class""" 

333 

334 # -- Updated the installed package data. 

335 self.installed_packages[name] = { 

336 "version": version, 

337 "platform": platform_id, 

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

339 "loaded-at": get_datetime_stamp(), 

340 "loaded-from": url, 

341 } 

342 # self._save() 

343 self._save_installed_packages() 

344 

345 def set_preferences_theme(self, theme: str): 

346 """Set prefer theme name.""" 

347 self.preferences["theme"] = theme 

348 self._save() 

349 self.apply_color_preferences() 

350 

351 def remove_package(self, name: str): 

352 """Remove a package from the profile file""" 

353 

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

355 del self.installed_packages[name] 

356 # self._save() 

357 self._save_installed_packages() 

358 

359 @staticmethod 

360 def apply_color_preferences(): 

361 """Apply currently preferred theme.""" 

362 # -- If not specified, read the theme from file. 

363 theme: str = Profile.read_preferences_theme() 

364 

365 # -- Apply to the apio console. 

366 apio_console.configure(theme_name=theme) 

367 

368 @staticmethod 

369 def read_preferences_theme(*, default: str = "light") -> Optional[str]: 

370 """Returns the value of the theme preference or default if not 

371 specified. This is a static method because we may need this value 

372 before creating the profile object, for example when printing command 

373 help. 

374 """ 

375 

376 profile_path = util.resolve_home_dir() / "profile.json" 

377 

378 if not profile_path.exists(): 

379 return default 

380 

381 with open(profile_path, "r", encoding="utf8") as f: 

382 # -- Get the colors preferences value, if exists. 

383 data = json.load(f) 

384 preferences = data.get("preferences", {}) 

385 theme = preferences.get("theme", None) 

386 

387 # -- Get the click context, if exists. 

388 return theme if theme else default 

389 

390 def get_installed_package_info(self, package_name: str) -> Tuple[str, str]: 

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

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

393 missing.""" 

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

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

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

397 return (package_version, platform_id) 

398 

399 def get_package_config( 

400 self, 

401 package_name: str, 

402 ) -> PackageRemoteConfig: 

403 """Given a package name, return the remote config information with the 

404 version and fetch information. 

405 """ 

406 

407 # -- Extract package's remote config. 

408 package_config = self.remote_config["packages"][package_name] 

409 repo_name = package_config["repository"]["name"] 

410 repo_organization = package_config["repository"]["organization"] 

411 release_tag = package_config["release"]["tag"] 

412 release_version = release_tag.replace("-", ".") 

413 release_file = package_config["release"]["package"] 

414 

415 return PackageRemoteConfig( 

416 repo_name=repo_name, 

417 repo_organization=repo_organization, 

418 release_version=release_version, 

419 release_tag=release_tag, 

420 release_file=release_file, 

421 ) 

422 

423 def _load_profile_file(self): 

424 """Load the profile file if exists, e.g. 

425 /home/obijuan/.apio/profile.json) 

426 """ 

427 

428 # -- If profile file doesn't exist then nothing to do. 

429 if not self._profile_path.exists(): 

430 return 

431 

432 # -- Read the profile file as a json dict. 

433 with open(self._profile_path, "r", encoding="utf8") as f: 

434 data = json.load(f) 

435 

436 # -- Determine if the cached remote config is usable. 

437 remote_config = data.get("remote-config", {}) 

438 config_apio_version = remote_config.get("metadata", {}).get( 

439 "loaded-by", "" 

440 ) 

441 config_usable = config_apio_version == util.get_apio_version_str() 

442 

443 # -- Extract the fields. If remote config is of a different apio 

444 # -- version, drop it. 

445 self.preferences = data.get("preferences", {}) 

446 self.installed_packages = data.get("installed-packages", {}) 

447 self._cached_remote_config = remote_config if config_usable else {} 

448 

449 def _load_installed_packages_file(self): 

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

451 /home/obijuan/.apio/packages/installed_packages.json) 

452 """ 

453 

454 if self._packages_index_path.exists(): 

455 

456 # -- Read the file as a json dict. 

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

458 self.installed_packages = json.load(f) 

459 

460 def _save(self): 

461 """Save the profile file""" 

462 

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

464 path = self._profile_path.parent 

465 if not path.exists(): 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 path.mkdir() 

467 

468 # -- Construct the json dict. 

469 data = {} 

470 if self.preferences: 

471 data["preferences"] = self.preferences 

472 

473 if self._cached_remote_config: 473 ↛ 477line 473 didn't jump to line 477 because the condition on line 473 was always true

474 data["remote-config"] = self._cached_remote_config 

475 

476 # -- Write to profile file. 

477 with open(self._profile_path, "w", encoding="utf8") as f: 

478 json.dump(data, f, indent=4) 

479 

480 # -- Dump for debugging. 

481 if util.is_debug(1): 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 cout("Saved profile:", style=EMPH3) 

483 cout(json.dumps(data, indent=2)) 

484 

485 def _save_installed_packages(self): 

486 """Save the installed packages file""" 

487 

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

489 path = self._packages_index_path.parent 

490 if not path.exists(): 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true

491 path.mkdir() 

492 

493 # -- Write to profile file. 

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

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

496 

497 # -- Dump for debugging. 

498 if util.is_debug(1): 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true

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

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

501 

502 def _handle_config_refresh_failure( 

503 self, *, msg: List[str], error_is_fatal: bool 

504 ): 

505 """Called to handle a failure of a remote config refresh.""" 

506 # -- Handle hard error. 

507 if error_is_fatal: 

508 cout(*msg, style=ERROR) 

509 sys.exit(1) 

510 

511 # -- Handle soft error. We can continue with a cached config. 

512 # -- Sanity check, a cached config exists. 

513 assert self._cached_remote_config, "No cached remote config" 

514 

515 # -- Print the soft warning. 

516 cout(*msg, style=INFO) 

517 cout("Will try again at a latter time.", style=INFO) 

518 

519 # -- Memorize the time of the attempt so we don't retry too often. 

520 metadata = self._cached_remote_config["metadata"] 

521 metadata["refresh-failure-on"] = get_datetime_stamp() 

522 self._save() 

523 

524 def _fetch_and_update_remote_config(self, *, error_is_fatal: bool) -> None: 

525 """Returns the apio remote config JSON dict.""" 

526 

527 # -- Fetch the config text. Returns None if error_is_fatal=False and 

528 # -- fetch failed. 

529 config_text: Optional[str] = self._fetch_remote_config_text( 

530 error_is_fatal=error_is_fatal 

531 ) 

532 

533 if config_text is None: 533 ↛ 536line 533 didn't jump to line 536 because the condition on line 533 was never true

534 # -- Sanity check, If error_is_fatal, _fetch_remote_config_text() 

535 # -- wouldn't return with None. 

536 assert not error_is_fatal 

537 return 

538 

539 # -- Print the file's content for debugging 

540 if util.is_debug(1): 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true

541 cout(config_text) 

542 

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

544 config_text = jsonc.to_json(config_text) 

545 

546 # -- Parse the remote JSON config file into a dict. 

547 try: 

548 remote_config = json.loads(config_text) 

549 

550 # -- Handle parsing error. 

551 except json.decoder.JSONDecodeError as exc: 

552 self._handle_config_refresh_failure( 

553 msg=[ 

554 "Failed to parse the latest Apio remote config file.", 

555 f"{exc}", 

556 ], 

557 error_is_fatal=error_is_fatal, 

558 ) 

559 return 

560 

561 # -- Do some checks and fail if invalid. This is not an exhaustive 

562 # -- check. 

563 ok = self._check_downloaded_remote_config( 

564 remote_config, error_is_fatal=error_is_fatal 

565 ) 

566 if not ok: 566 ↛ 567line 566 didn't jump to line 567 because the condition on line 566 was never true

567 return 

568 

569 # -- Append remote config metadata. This also clear the 

570 # -- "refresh-failure-on" field if exists. 

571 metadata_dict = {} 

572 metadata_dict["loaded-by"] = util.get_apio_version_str() 

573 metadata_dict["loaded-at"] = get_datetime_stamp() 

574 metadata_dict["loaded-from"] = self.remote_config_url 

575 remote_config["metadata"] = metadata_dict 

576 

577 self._cached_remote_config = remote_config 

578 self._save() 

579 

580 def _check_downloaded_remote_config( 

581 self, remote_config: Dict, error_is_fatal: bool 

582 ) -> bool: 

583 """Check the downloaded remote config has a valid structure.""" 

584 try: 

585 validate(instance=remote_config, schema=REMOTE_CONFIG_SCHEMA) 

586 except ValidationError as e: 

587 # -- Error. 

588 msg = ["Fetched remote config failed validation.", str(e)] 

589 self._handle_config_refresh_failure( 

590 msg=msg, error_is_fatal=error_is_fatal 

591 ) 

592 return False 

593 

594 # -- Ok. 

595 return True 

596 

597 def _fetch_remote_config_text(self, error_is_fatal: bool) -> Optional[str]: 

598 """Fetches and returns the apio remote config JSON text. In case 

599 of an error, returns None.""" 

600 

601 # pylint: disable=broad-exception-caught 

602 

603 # -- Announce the remote config url 

604 cout(f"Fetching '{self.remote_config_url}'") 

605 

606 # -- If the URL has a file protocol, read from the file. This 

607 # -- is used mostly for testing of a new package version. 

608 if self.remote_config_url.startswith("file://"): 608 ↛ 630line 608 didn't jump to line 630 because the condition on line 608 was always true

609 file_path = self.remote_config_url[7:] 

610 try: 

611 with open(file_path, encoding="utf-8") as f: 

612 file_text = f.read() 

613 except Exception as e: 

614 # -- Since local config file can be fixed and doesn't depend 

615 # -- on availability of a remote server, we make this a fatal 

616 # -- error instead of a soft error. 

617 self._handle_config_refresh_failure( 

618 msg=["Failed to read a local config file.", str(e)], 

619 error_is_fatal=True, 

620 ) 

621 

622 # -- Local file read OK. 

623 return file_text 

624 

625 # -- Here is the normal case where the config url is not of a local 

626 # -- file but at a remote URL. 

627 

628 # -- Fetch the remote config. With timeout = 10, this failed a 

629 # -- few times on github workflow tests so increased to 25. 

630 try: 

631 resp: requests.Response = requests.get( 

632 self.remote_config_url, timeout=25 

633 ) 

634 error_msg = None 

635 except Exception as e: 

636 error_msg = str(e) 

637 

638 # -- Error codes such as 404 don't cause an exception so we handle 

639 # -- them here separately. 

640 if (error_msg is None) and (resp.status_code != 200): 

641 error_msg = ( 

642 f"Expected HTTP status code 200, got {resp.status_code}." 

643 ) 

644 

645 # -- If an error was found then handle it. 

646 if error_msg is not None: 

647 self._handle_config_refresh_failure( 

648 msg=[ 

649 "Downloading of the latest Apio remote config " 

650 "file failed.", 

651 error_msg, 

652 ], 

653 error_is_fatal=error_is_fatal, 

654 ) 

655 return None 

656 

657 # -- Done ok. 

658 assert resp.text is not None 

659 return resp.text