Coverage for apio/profile.py: 75%

239 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-24 03:51 +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(ts1: str, ts2: str, default: Any) -> int: 

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

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

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

116 is invalid.""" 

117 

118 # -- The parsing format. 

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

120 

121 # -- Convert to timedates 

122 try: 

123 datetime1 = datetime.strptime(ts1, fmt) 

124 datetime2 = datetime.strptime(ts2, fmt) 

125 except ValueError: 

126 return default 

127 

128 # -- Round to beginning of day. 

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

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

131 

132 # -- Compute the diff in days. 

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

134 

135 # -- All done. 

136 assert isinstance(delta_days, int) 

137 return delta_days 

138 

139 

140def minutes_between_datetime_stamps(ts1: str, ts2: str, default: Any) -> int: 

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

142 the timestamps is invalid.""" 

143 

144 # -- The parsing format. 

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

146 

147 # -- Convert to timedates 

148 try: 

149 datetime1 = datetime.strptime(ts1, fmt) 

150 datetime2 = datetime.strptime(ts2, fmt) 

151 except ValueError: 

152 return default 

153 

154 # -- Calculate the diff in minutes. 

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

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

157 return delta_minutes 

158 

159 

160class Profile: 

161 """Class for managing the apio profile file 

162 ex. ~/.apio/profile.json 

163 """ 

164 

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

166 

167 # -- Only these instance vars are allowed. 

168 __slots__ = ( 

169 "_profile_path", 

170 "_packages_index_path", 

171 "remote_config_url", 

172 "remote_config_ttl_days", 

173 "remote_config_retry_minutes", 

174 "_remote_config_policy", 

175 "_cached_remote_config", 

176 "preferences", 

177 "installed_packages", 

178 ) 

179 

180 def __init__( 

181 self, 

182 home_dir: Path, 

183 packages_dir: Path, 

184 remote_config_url_template: str, 

185 remote_config_ttl_days: int, 

186 remote_config_retry_minutes: int, 

187 remote_config_policy: RemoteConfigPolicy, 

188 ): 

189 """remote_config_url_template is a url string with the 

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

191 version. '""" 

192 

193 # pylint: disable=too-many-arguments 

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

195 

196 # -- Sanity check 

197 assert isinstance(remote_config_ttl_days, int) 

198 assert 0 < remote_config_ttl_days <= 30 

199 

200 # -- Sanity check 

201 assert isinstance(remote_config_retry_minutes, int) 

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

203 

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

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

206 # -- not used. 

207 ver_tuple = util.get_apio_version_tuple() 

208 url = remote_config_url_template 

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

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

211 self.remote_config_url = url 

212 

213 # -- Save remote url ttl setting. 

214 self.remote_config_ttl_days = remote_config_ttl_days 

215 

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

217 self.remote_config_retry_minutes = remote_config_retry_minutes 

218 

219 # -- Save remote config policy. 

220 self._remote_config_policy = remote_config_policy 

221 

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

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

224 

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

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

227 

228 # ---- Set the default parameters 

229 

230 # User preferences 

231 self.preferences = {} 

232 

233 # -- Installed package versions 

234 self.installed_packages = {} 

235 

236 # -- A copy of remote config. 

237 self._cached_remote_config = {} 

238 

239 # -- Cache the profile file path 

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

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

242 

243 # -- Cache the packages index file path 

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

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

246 

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

248 self._load_profile_file() 

249 

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

251 self._load_installed_packages_file() 

252 

253 # -- Apply config policy 

254 self._apply_remote_config_policy() 

255 

256 def _apply_remote_config_policy(self) -> None: 

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

258 policy for this invocation.""" 

259 

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

261 if self._remote_config_policy == RemoteConfigPolicy.GET_FRESH: 

262 self._fetch_and_update_remote_config(error_is_fatal=True) 

263 return 

264 

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

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

267 assert self._remote_config_policy == RemoteConfigPolicy.CACHED_OK 

268 if not self._cached_remote_config: 

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

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

271 self._fetch_and_update_remote_config(error_is_fatal=True) 

272 return 

273 

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

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

276 # 

277 # -- Get the cached config metadata. 

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

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

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

281 

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

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

284 url_changed = last_fetch_url != self.remote_config_url 

285 

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

287 datetime_stamp_now = get_datetime_stamp() 

288 days_since_last_fetch = days_between_datetime_stamps( 

289 last_fetch_timestamp, datetime_stamp_now, default=99999 

290 ) 

291 time_valid = 0 <= days_since_last_fetch < self.remote_config_ttl_days 

292 

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

294 # -- failed. 

295 refresh_failure_timestamp = cashed_config_metadata.get( 

296 "refresh-failure-on", "" 

297 ) 

298 minutes_since_refresh_failure = minutes_between_datetime_stamps( 

299 refresh_failure_timestamp, datetime_stamp_now, default=99999 

300 ) 

301 refresh_failed_recently = ( 

302 0 

303 <= minutes_since_refresh_failure 

304 < self.remote_config_retry_minutes 

305 ) 

306 

307 # -- Dump info for debugging. 

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

309 cout( 

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

311 f"{minutes_since_refresh_failure=}, " 

312 f"{refresh_failed_recently=}", 

313 style=EMPH3, 

314 ) 

315 

316 # -- Fetch the new config if needed. 

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

318 if not refresh_failed_recently: 

319 self._fetch_and_update_remote_config(error_is_fatal=False) 

320 

321 @property 

322 def remote_config(self) -> Dict: 

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

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

325 return self._cached_remote_config 

326 

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

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

329 

330 # -- Updated the installed package data. 

331 self.installed_packages[name] = { 

332 "version": version, 

333 "platform": platform_id, 

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

335 "loaded-at": get_datetime_stamp(), 

336 "loaded-from": url, 

337 } 

338 # self._save() 

339 self._save_installed_packages() 

340 

341 def set_preferences_theme(self, theme: str): 

342 """Set prefer theme name.""" 

343 self.preferences["theme"] = theme 

344 self._save() 

345 self.apply_color_preferences() 

346 

347 def remove_package(self, name: str): 

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

349 

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

351 del self.installed_packages[name] 

352 # self._save() 

353 self._save_installed_packages() 

354 

355 @staticmethod 

356 def apply_color_preferences(): 

357 """Apply currently preferred theme.""" 

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

359 theme: str = Profile.read_preferences_theme() 

360 

361 # -- Apply to the apio console. 

362 apio_console.configure(theme_name=theme) 

363 

364 @staticmethod 

365 def read_preferences_theme(*, default: str = "light") -> str: 

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

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

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

369 help. 

370 """ 

371 

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

373 

374 if not profile_path.exists(): 

375 return default 

376 

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

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

379 data = json.load(f) 

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

381 theme = preferences.get("theme", default) 

382 

383 return theme 

384 

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

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

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

388 missing.""" 

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

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

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

392 return (package_version, platform_id) 

393 

394 def get_package_config( 

395 self, 

396 package_name: str, 

397 ) -> PackageRemoteConfig: 

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

399 version and fetch information. 

400 """ 

401 

402 # -- Extract package's remote config. 

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

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

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

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

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

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

409 

410 return PackageRemoteConfig( 

411 repo_name=repo_name, 

412 repo_organization=repo_organization, 

413 release_version=release_version, 

414 release_tag=release_tag, 

415 release_file=release_file, 

416 ) 

417 

418 def _load_profile_file(self): 

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

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

421 """ 

422 

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

424 if not self._profile_path.exists(): 

425 return 

426 

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

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

429 data = json.load(f) 

430 

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

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

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

434 "loaded-by", "" 

435 ) 

436 config_usable = config_apio_version == util.get_apio_version_str() 

437 

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

439 # -- version, drop it. 

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

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

442 self._cached_remote_config = remote_config if config_usable else {} 

443 

444 def _load_installed_packages_file(self): 

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

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

447 """ 

448 

449 if self._packages_index_path.exists(): 

450 

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

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

453 self.installed_packages = json.load(f) 

454 

455 def _save(self): 

456 """Save the profile file""" 

457 

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

459 path = self._profile_path.parent 

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

461 path.mkdir() 

462 

463 # -- Construct the json dict. 

464 data = {} 

465 if self.preferences: 

466 data["preferences"] = self.preferences 

467 

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

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

470 

471 # -- Write to profile file. 

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

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

474 

475 # -- Dump for debugging. 

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

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

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

479 

480 def _save_installed_packages(self): 

481 """Save the installed packages file""" 

482 

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

484 path = self._packages_index_path.parent 

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

486 path.mkdir() 

487 

488 # -- Write to profile file. 

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

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

491 

492 # -- Dump for debugging. 

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

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

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

496 

497 def _handle_config_refresh_failure( 

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

499 ): 

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

501 # -- Handle hard error. 

502 if error_is_fatal: 

503 cout(*msg, style=ERROR) 

504 sys.exit(1) 

505 

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

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

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

509 

510 # -- Print the soft warning. 

511 cout(*msg, style=INFO) 

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

513 

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

515 metadata = self._cached_remote_config["metadata"] 

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

517 self._save() 

518 

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

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

521 

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

523 # -- fetch failed. 

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

525 error_is_fatal=error_is_fatal 

526 ) 

527 

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

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

530 # -- wouldn't return with None. 

531 assert not error_is_fatal 

532 return 

533 

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

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

536 cout(config_text) 

537 

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

539 config_text = jsonc.to_json(config_text) 

540 

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

542 try: 

543 remote_config = json.loads(config_text) 

544 

545 # -- Handle parsing error. 

546 except json.decoder.JSONDecodeError as exc: 

547 self._handle_config_refresh_failure( 

548 msg=[ 

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

550 f"{exc}", 

551 ], 

552 error_is_fatal=error_is_fatal, 

553 ) 

554 return 

555 

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

557 # -- check. 

558 ok = self._check_downloaded_remote_config( 

559 remote_config, error_is_fatal=error_is_fatal 

560 ) 

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

562 return 

563 

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

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

566 metadata_dict = {} 

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

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

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

570 remote_config["metadata"] = metadata_dict 

571 

572 self._cached_remote_config = remote_config 

573 self._save() 

574 

575 def _check_downloaded_remote_config( 

576 self, remote_config: Dict, error_is_fatal: bool 

577 ) -> bool: 

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

579 try: 

580 validate(instance=remote_config, schema=REMOTE_CONFIG_SCHEMA) 

581 except ValidationError as e: 

582 # -- Error. 

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

584 self._handle_config_refresh_failure( 

585 msg=msg, error_is_fatal=error_is_fatal 

586 ) 

587 return False 

588 

589 # -- Ok. 

590 return True 

591 

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

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

594 of an error, returns None.""" 

595 

596 # pylint: disable=broad-exception-caught 

597 

598 # -- Announce the remote config url 

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

600 

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

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

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

604 file_path = self.remote_config_url[7:] 

605 try: 

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

607 file_text = f.read() 

608 except Exception as e: 

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

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

611 # -- error instead of a soft error. 

612 self._handle_config_refresh_failure( 

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

614 error_is_fatal=True, 

615 ) 

616 

617 # -- Local file read OK. 

618 return file_text 

619 

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

621 # -- file but at a remote URL. 

622 

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

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

625 try: 

626 resp: requests.Response = requests.get( 

627 self.remote_config_url, timeout=25 

628 ) 

629 error_msg = None 

630 except Exception as e: 

631 error_msg = str(e) 

632 

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

634 # -- them here separately. 

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

636 error_msg = ( 

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

638 ) 

639 

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

641 if error_msg is not None: 

642 self._handle_config_refresh_failure( 

643 msg=[ 

644 "Downloading of the latest Apio remote config " 

645 "file failed.", 

646 error_msg, 

647 ], 

648 error_is_fatal=error_is_fatal, 

649 ) 

650 return None 

651 

652 # -- Done ok. 

653 assert resp.text is not None 

654 return resp.text