Coverage for apio/profile.py: 79%

235 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-06 10:20 +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 "version", 

54 "release-tag", 

55 "package-file", 

56 ], 

57 "properties": { 

58 # -- Version 

59 "version": { 

60 "type": "string", 

61 "pattern": r"^\d{4}\.\d{2}\.\d{2}$", 

62 }, 

63 # -- Release tag 

64 "release-tag": {"type": "string"}, 

65 # -- Package file 

66 "package-file": {"type": "string"}, 

67 }, 

68 "additionalProperties": False, 

69 }, 

70 }, 

71 "additionalProperties": False, 

72 } 

73 }, 

74 "additionalProperties": False, 

75 } 

76 }, 

77 "additionalProperties": False, 

78} 

79 

80 

81class RemoteConfigPolicy(Enum): 

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

83 

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

85 # -- not too old. 

86 CACHED_OK = 1 

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

88 # -- invocation of Apio is required. 

89 GET_FRESH = 2 

90 

91 

92@dataclass(frozen=True) 

93class PackageRemoteConfig: 

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

95 

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

97 repo_name: str 

98 # -- E.g. "FPGAwars" 

99 repo_organization: str 

100 # -- E.g. "0.2.3" 

101 release_version: str 

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

103 release_tag: str 

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

105 release_file: str 

106 

107 

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

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

110 if dt is None: 

111 dt = datetime.now() 

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

113 

114 

115def days_between_datetime_stamps( 

116 ts1: str, ts2: str, default: Any 

117) -> Optional[int]: 

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

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

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

121 is invalid.""" 

122 

123 # -- The parsing format. 

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

125 

126 # -- Convert to timedates 

127 try: 

128 datetime1 = datetime.strptime(ts1, fmt) 

129 datetime2 = datetime.strptime(ts2, fmt) 

130 except ValueError: 

131 return default 

132 

133 # -- Round to beginning of day. 

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

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

136 

137 # -- Compute the diff in days. 

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

139 

140 # -- All done. 

141 assert isinstance(delta_days, int) 

142 return delta_days 

143 

144 

145def minutes_between_datetime_stamps( 

146 ts1: str, ts2: str, default: Any 

147) -> Optional[int]: 

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

149 the timestamps is invalid.""" 

150 

151 # -- The parsing format. 

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

153 

154 # -- Convert to timedates 

155 try: 

156 datetime1 = datetime.strptime(ts1, fmt) 

157 datetime2 = datetime.strptime(ts2, fmt) 

158 except ValueError: 

159 return default 

160 

161 # -- Calculate the diff in minutes. 

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

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

164 return delta_minutes 

165 

166 

167class Profile: 

168 """Class for managing the apio profile file 

169 ex. ~/.apio/profile.json 

170 """ 

171 

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

173 

174 # -- Only these instance vars are allowed. 

175 __slots__ = ( 

176 "_profile_path", 

177 "_packages_index_path", 

178 "remote_config_url", 

179 "remote_config_ttl_days", 

180 "remote_config_retry_minutes", 

181 "_remote_config_policy", 

182 "_cached_remote_config", 

183 "preferences", 

184 "installed_packages", 

185 ) 

186 

187 def __init__( 

188 self, 

189 home_dir: Path, 

190 packages_dir: Path, 

191 remote_config_url_template: str, 

192 remote_config_ttl_days: int, 

193 remote_config_retry_minutes: int, 

194 remote_config_policy: RemoteConfigPolicy, 

195 ): 

196 """remote_config_url_template is a url string with a "{V}" 

197 placeholder for the apio version such as "0.9.6.""" 

198 

199 # pylint: disable=too-many-arguments 

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

201 

202 # -- Sanity check 

203 assert isinstance(remote_config_ttl_days, int) 

204 assert 0 < remote_config_ttl_days <= 30 

205 

206 # -- Sanity check 

207 assert isinstance(remote_config_retry_minutes, int) 

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

209 

210 # -- Resolve and cache the remote config url. We replace any {V} with 

211 # -- the apio version such as "0.9.6". 

212 self.remote_config_url = remote_config_url_template.replace( 

213 "{V}", util.get_apio_version() 

214 ) 

215 

216 # -- Save remote url ttl setting. 

217 self.remote_config_ttl_days = remote_config_ttl_days 

218 

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

220 self.remote_config_retry_minutes = remote_config_retry_minutes 

221 

222 # -- Save remote config policy. 

223 self._remote_config_policy = remote_config_policy 

224 

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

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

227 

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

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

230 

231 # ---- Set the default parameters 

232 

233 # User preferences 

234 self.preferences = {} 

235 

236 # -- Installed package versions 

237 self.installed_packages = {} 

238 

239 # -- A copy of remote config. 

240 self._cached_remote_config = {} 

241 

242 # -- Cache the profile file path 

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

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

245 

246 # -- Cache the packages index file path 

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

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

249 

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

251 self._load_profile_file() 

252 

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

254 self._load_installed_packages_file() 

255 

256 # -- Apply config policy 

257 self._apply_remote_config_policy() 

258 

259 def _apply_remote_config_policy(self) -> None: 

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

261 policy for this invocation.""" 

262 

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

264 if self._remote_config_policy == RemoteConfigPolicy.GET_FRESH: 

265 self._fetch_and_update_remote_config(error_is_fatal=True) 

266 return 

267 

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

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

270 assert self._remote_config_policy == RemoteConfigPolicy.CACHED_OK 

271 if not self._cached_remote_config: 

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

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

274 self._fetch_and_update_remote_config(error_is_fatal=True) 

275 return 

276 

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

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

279 # 

280 # -- Get the cached config metadata. 

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

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

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

284 

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

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

287 url_changed = last_fetch_url != self.remote_config_url 

288 

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

290 datetime_stamp_now = get_datetime_stamp() 

291 days_since_last_fetch = days_between_datetime_stamps( 

292 last_fetch_timestamp, datetime_stamp_now, default=99999 

293 ) 

294 time_valid = 0 <= days_since_last_fetch < self.remote_config_ttl_days 

295 

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

297 # -- failed. 

298 refresh_failure_timestamp = cashed_config_metadata.get( 

299 "refresh-failure-on", "" 

300 ) 

301 minutes_since_refresh_failure = minutes_between_datetime_stamps( 

302 refresh_failure_timestamp, datetime_stamp_now, default=99999 

303 ) 

304 refresh_failed_recently = ( 

305 0 

306 <= minutes_since_refresh_failure 

307 < self.remote_config_retry_minutes 

308 ) 

309 

310 # -- Dump info for debugging. 

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

312 cout( 

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

314 f"{minutes_since_refresh_failure=}, " 

315 f"{refresh_failed_recently=}", 

316 style=EMPH3, 

317 ) 

318 

319 # -- Fetch the new config if needed. 

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

321 if not refresh_failed_recently: 

322 self._fetch_and_update_remote_config(error_is_fatal=False) 

323 

324 @property 

325 def remote_config(self) -> Dict: 

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

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

328 return self._cached_remote_config 

329 

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

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

332 

333 # -- Updated the installed package data. 

334 self.installed_packages[name] = { 

335 "version": version, 

336 "platform": platform_id, 

337 "loaded-by": util.get_apio_version(), 

338 "loaded-at": get_datetime_stamp(), 

339 "loaded-from": url, 

340 } 

341 # self._save() 

342 self._save_installed_packages() 

343 

344 def set_preferences_theme(self, theme: str): 

345 """Set prefer theme name.""" 

346 self.preferences["theme"] = theme 

347 self._save() 

348 self.apply_color_preferences() 

349 

350 def remove_package(self, name: str): 

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

352 

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

354 del self.installed_packages[name] 

355 # self._save() 

356 self._save_installed_packages() 

357 

358 @staticmethod 

359 def apply_color_preferences(): 

360 """Apply currently preferred theme.""" 

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

362 theme: str = Profile.read_preferences_theme() 

363 

364 # -- Apply to the apio console. 

365 apio_console.configure(theme_name=theme) 

366 

367 @staticmethod 

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

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

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

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

372 help. 

373 """ 

374 

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

376 

377 if not profile_path.exists(): 

378 return default 

379 

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

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

382 data = json.load(f) 

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

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

385 

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

387 return theme if theme else default 

388 

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

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

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

392 missing.""" 

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

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

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

396 return (package_version, platform_id) 

397 

398 def get_package_config( 

399 self, 

400 package_name: str, 

401 ) -> PackageRemoteConfig: 

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

403 version and fetch information. 

404 """ 

405 

406 # -- Extract package's remote config. 

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

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

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

410 release_version = package_config["release"]["version"] 

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

412 release_file = package_config["release"]["package-file"] 

413 

414 return PackageRemoteConfig( 

415 repo_name=repo_name, 

416 repo_organization=repo_organization, 

417 release_version=release_version, 

418 release_tag=release_tag, 

419 release_file=release_file, 

420 ) 

421 

422 def _load_profile_file(self): 

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

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

425 """ 

426 

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

428 if not self._profile_path.exists(): 

429 return 

430 

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

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

433 data = json.load(f) 

434 

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

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

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

438 "loaded-by", "" 

439 ) 

440 config_usable = config_apio_version == util.get_apio_version() 

441 

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

443 # -- version, drop it. 

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

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

446 self._cached_remote_config = remote_config if config_usable else {} 

447 

448 def _load_installed_packages_file(self): 

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

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

451 """ 

452 

453 if self._packages_index_path.exists(): 

454 

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

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

457 self.installed_packages = json.load(f) 

458 

459 def _save(self): 

460 """Save the profile file""" 

461 

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

463 path = self._profile_path.parent 

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

465 path.mkdir() 

466 

467 # -- Construct the json dict. 

468 data = {} 

469 if self.preferences: 

470 data["preferences"] = self.preferences 

471 

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

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

474 

475 # -- Write to profile file. 

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

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

478 

479 # -- Dump for debugging. 

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

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

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

483 

484 def _save_installed_packages(self): 

485 """Save the installed packages file""" 

486 

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

488 path = self._packages_index_path.parent 

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

490 path.mkdir() 

491 

492 # -- Write to profile file. 

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

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

495 

496 # -- Dump for debugging. 

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

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

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

500 

501 def _handle_config_refresh_failure( 

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

503 ): 

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

505 # -- Handle hard error. 

506 if error_is_fatal: 

507 cout(*msg, style=ERROR) 

508 sys.exit(1) 

509 

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

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

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

513 

514 # -- Print the soft warning. 

515 cout(*msg, style=INFO) 

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

517 

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

519 metadata = self._cached_remote_config["metadata"] 

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

521 self._save() 

522 

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

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

525 

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

527 # -- fetch failed. 

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

529 error_is_fatal=error_is_fatal 

530 ) 

531 

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

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

534 # -- wouldn't return with None. 

535 assert not error_is_fatal 

536 return 

537 

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

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

540 cout(config_text) 

541 

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

543 config_text = jsonc.to_json(config_text) 

544 

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

546 try: 

547 remote_config = json.loads(config_text) 

548 

549 # -- Handle parsing error. 

550 except json.decoder.JSONDecodeError as exc: 

551 self._handle_config_refresh_failure( 

552 msg=[ 

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

554 f"{exc}", 

555 ], 

556 error_is_fatal=error_is_fatal, 

557 ) 

558 return 

559 

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

561 # -- check. 

562 ok = self._check_downloaded_remote_config( 

563 remote_config, error_is_fatal=error_is_fatal 

564 ) 

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

566 return 

567 

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

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

570 metadata_dict = {} 

571 metadata_dict["loaded-by"] = util.get_apio_version() 

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

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

574 remote_config["metadata"] = metadata_dict 

575 

576 self._cached_remote_config = remote_config 

577 self._save() 

578 

579 def _check_downloaded_remote_config( 

580 self, remote_config: Dict, error_is_fatal: bool 

581 ) -> bool: 

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

583 try: 

584 validate(instance=remote_config, schema=REMOTE_CONFIG_SCHEMA) 

585 except ValidationError as e: 

586 # -- Error. 

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

588 self._handle_config_refresh_failure( 

589 msg=msg, error_is_fatal=error_is_fatal 

590 ) 

591 return False 

592 

593 # -- Ok. 

594 return True 

595 

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

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

598 of an error, returns None.""" 

599 

600 # pylint: disable=broad-exception-caught 

601 

602 # -- Announce the remote config url 

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

604 

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

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

607 if self.remote_config_url.startswith("file://"): 

608 file_path = self.remote_config_url[7:] 

609 try: 

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

611 file_text = f.read() 

612 except Exception as e: 

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

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

615 # -- error instead of a soft error. 

616 self._handle_config_refresh_failure( 

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

618 error_is_fatal=True, 

619 ) 

620 

621 # -- Local file read OK. 

622 return file_text 

623 

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

625 # -- file but at a remote URL. 

626 

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

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

629 try: 

630 resp: requests.Response = requests.get( 

631 self.remote_config_url, timeout=25 

632 ) 

633 error_msg = None 

634 except Exception as e: 

635 error_msg = str(e) 

636 

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

638 # -- them here separately. 

639 if (error_msg is None) and (resp.status_code != 200): 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true

640 error_msg = ( 

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

642 ) 

643 

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

645 if error_msg is not None: 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true

646 self._handle_config_refresh_failure( 

647 msg=[ 

648 "Downloading of the latest Apio remote config " 

649 "file failed.", 

650 error_msg, 

651 ], 

652 error_is_fatal=error_is_fatal, 

653 ) 

654 return None 

655 

656 # -- Done ok. 

657 assert resp.text is not None 

658 return resp.text