Coverage for apio/profile.py: 78%

267 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 01:55 +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, cerror 

20from apio.common.apio_themes import THEMES_TABLE 

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

22from apio.utils import util, jsonc 

23 

24 

25def _check_json_dict(value: Any, desc: str) -> None: 

26 """Raises a ValueError if 'value' is not a dict. For validating the 

27 shape of values from user editable json files.""" 

28 if not isinstance(value, dict): 

29 raise ValueError( 

30 f"Expected {desc} to be a json dict, " 

31 f"found {type(value).__name__}" 

32 ) 

33 

34 

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

36REMOTE_CONFIG_SCHEMA = { 

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

38 "type": "object", 

39 "required": ["packages"], 

40 "properties": { 

41 # -- Packages 

42 "packages": { 

43 "type": "object", 

44 "patternProperties": { 

45 "^.*$": { 

46 "type": "object", 

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

48 "properties": { 

49 # -- Repository 

50 "repository": { 

51 "type": "object", 

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

53 "properties": { 

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

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

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

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

58 }, 

59 "additionalProperties": False, 

60 }, 

61 # -- Release. 

62 "release": { 

63 "type": "object", 

64 "required": [ 

65 "tag", 

66 "package", 

67 ], 

68 "properties": { 

69 # -- Tag 

70 "tag": { 

71 "type": "string", 

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

73 }, 

74 # -- Package 

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

76 }, 

77 "additionalProperties": False, 

78 }, 

79 }, 

80 "additionalProperties": False, 

81 } 

82 }, 

83 "additionalProperties": False, 

84 } 

85 }, 

86 "additionalProperties": False, 

87} 

88 

89 

90class RemoteConfigPolicy(Enum): 

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

92 

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

94 # -- not too old. 

95 CACHED_OK = 1 

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

97 # -- invocation of Apio is required. 

98 GET_FRESH = 2 

99 

100 

101@dataclass(frozen=True) 

102class PackageRemoteConfig: 

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

104 

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

106 repo_name: str 

107 # -- E.g. "FPGAwars" 

108 repo_organization: str 

109 # -- E.g. "0.2.3" 

110 release_version: str 

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

112 release_tag: str 

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

114 release_file: str 

115 

116 

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

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

119 if dt is None: 

120 dt = datetime.now() 

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

122 

123 

124def days_between_datetime_stamps(ts1: str, ts2: str, default: Any) -> int: 

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

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

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

128 is invalid.""" 

129 

130 # -- The parsing format. 

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

132 

133 # -- Convert to timedates 

134 try: 

135 datetime1 = datetime.strptime(ts1, fmt) 

136 datetime2 = datetime.strptime(ts2, fmt) 

137 except ValueError: 

138 return default 

139 

140 # -- Round to beginning of day. 

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

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

143 

144 # -- Compute the diff in days. 

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

146 

147 # -- All done. 

148 assert isinstance(delta_days, int) 

149 return delta_days 

150 

151 

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

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

154 the timestamps is invalid.""" 

155 

156 # -- The parsing format. 

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

158 

159 # -- Convert to timedates 

160 try: 

161 datetime1 = datetime.strptime(ts1, fmt) 

162 datetime2 = datetime.strptime(ts2, fmt) 

163 except ValueError: 

164 return default 

165 

166 # -- Calculate the diff in minutes. 

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

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

169 return delta_minutes 

170 

171 

172class Profile: 

173 """Class for managing the apio profile file 

174 ex. ~/.apio/profile.json 

175 """ 

176 

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

178 

179 # -- Only these instance vars are allowed. 

180 __slots__ = ( 

181 "_profile_path", 

182 "_packages_index_path", 

183 "remote_config_url", 

184 "remote_config_ttl_days", 

185 "remote_config_retry_minutes", 

186 "_remote_config_policy", 

187 "_cached_remote_config", 

188 "preferences", 

189 "installed_packages", 

190 ) 

191 

192 def __init__( 

193 self, 

194 home_dir: Path, 

195 packages_dir: Path, 

196 remote_config_url_template: str, 

197 remote_config_ttl_days: int, 

198 remote_config_retry_minutes: int, 

199 remote_config_policy: RemoteConfigPolicy, 

200 ): 

201 """remote_config_url_template is a url string with the 

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

203 version. '""" 

204 

205 # pylint: disable=too-many-arguments 

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

207 

208 # -- Sanity check 

209 assert isinstance(remote_config_ttl_days, int) 

210 assert 0 < remote_config_ttl_days <= 30 

211 

212 # -- Sanity check 

213 assert isinstance(remote_config_retry_minutes, int) 

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

215 

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

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

218 # -- not used. 

219 ver_tuple = util.get_apio_version_tuple() 

220 url = remote_config_url_template 

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

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

223 self.remote_config_url = url 

224 

225 # -- Save remote url ttl setting. 

226 self.remote_config_ttl_days = remote_config_ttl_days 

227 

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

229 self.remote_config_retry_minutes = remote_config_retry_minutes 

230 

231 # -- Save remote config policy. 

232 self._remote_config_policy = remote_config_policy 

233 

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

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

236 

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

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

239 

240 # ---- Set the default parameters 

241 

242 # User preferences 

243 self.preferences = {} 

244 

245 # -- Installed package versions 

246 self.installed_packages = {} 

247 

248 # -- A copy of remote config. 

249 self._cached_remote_config = {} 

250 

251 # -- Cache the profile file path 

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

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

254 

255 # -- Cache the packages index file path 

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

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

258 

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

260 self._load_profile_file() 

261 

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

263 self._load_installed_packages_file() 

264 

265 # -- Apply config policy 

266 self._apply_remote_config_policy() 

267 

268 def _apply_remote_config_policy(self) -> None: 

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

270 policy for this invocation.""" 

271 

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

273 if self._remote_config_policy == RemoteConfigPolicy.GET_FRESH: 

274 self._fetch_and_update_remote_config(error_is_fatal=True) 

275 return 

276 

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

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

279 assert self._remote_config_policy == RemoteConfigPolicy.CACHED_OK 

280 if not self._cached_remote_config: 

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

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

283 self._fetch_and_update_remote_config(error_is_fatal=True) 

284 return 

285 

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

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

288 # 

289 # -- Get the cached config metadata. 

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

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

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

293 

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

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

296 url_changed = last_fetch_url != self.remote_config_url 

297 

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

299 datetime_stamp_now = get_datetime_stamp() 

300 days_since_last_fetch = days_between_datetime_stamps( 

301 last_fetch_timestamp, datetime_stamp_now, default=99999 

302 ) 

303 time_valid = 0 <= days_since_last_fetch < self.remote_config_ttl_days 

304 

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

306 # -- failed. 

307 refresh_failure_timestamp = cashed_config_metadata.get( 

308 "refresh-failure-on", "" 

309 ) 

310 minutes_since_refresh_failure = minutes_between_datetime_stamps( 

311 refresh_failure_timestamp, datetime_stamp_now, default=99999 

312 ) 

313 refresh_failed_recently = ( 

314 0 

315 <= minutes_since_refresh_failure 

316 < self.remote_config_retry_minutes 

317 ) 

318 

319 # -- Dump info for debugging. 

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

321 cout( 

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

323 f"{minutes_since_refresh_failure=}, " 

324 f"{refresh_failed_recently=}", 

325 style=EMPH3, 

326 ) 

327 

328 # -- Fetch the new config if needed. 

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

330 if not refresh_failed_recently: 

331 self._fetch_and_update_remote_config(error_is_fatal=False) 

332 

333 @property 

334 def remote_config(self) -> Dict: 

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

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

337 return self._cached_remote_config 

338 

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

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

341 

342 # -- Updated the installed package data. 

343 self.installed_packages[name] = { 

344 "version": version, 

345 "platform": platform_id, 

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

347 "loaded-at": get_datetime_stamp(), 

348 "loaded-from": url, 

349 } 

350 # self._save() 

351 self._save_installed_packages() 

352 

353 def set_preferences_theme(self, theme: str): 

354 """Set prefer theme name.""" 

355 self.preferences["theme"] = theme 

356 self._save() 

357 self.apply_color_preferences() 

358 

359 def remove_package(self, name: str): 

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

361 

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

363 del self.installed_packages[name] 

364 # self._save() 

365 self._save_installed_packages() 

366 

367 @staticmethod 

368 def apply_color_preferences(): 

369 """Apply currently preferred theme.""" 

370 # -- Make sure the console is configured, with the default theme, 

371 # -- before reading the preferences. Reading the preferences resolves 

372 # -- the apio home dir which may exit with a console error message, 

373 # -- for example if the home dir path contains a space. 

374 apio_console.configure() 

375 

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

377 theme: str = Profile.read_preferences_theme() 

378 

379 # -- Apply to the apio console. 

380 apio_console.configure(theme_name=theme) 

381 

382 @staticmethod 

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

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

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

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

387 help. 

388 """ 

389 

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

391 

392 if not profile_path.exists(): 

393 return default 

394 

395 try: 

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

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

398 data = json.load(f) 

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

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

401 except (OSError, ValueError, AttributeError): 

402 # -- A corrupt profile file. Not reporting it here since 

403 # -- _load_profile_file() reports it with a proper error message. 

404 return default 

405 

406 # -- Fall back to the default for unknown theme names or values, 

407 # -- e.g. from a hand edited or old profile file, since 

408 # -- apio_console.configure() accepts only known theme names. 

409 if not isinstance(theme, str) or theme not in THEMES_TABLE: 

410 return default 

411 

412 return theme 

413 

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

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

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

417 missing.""" 

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

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

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

421 return (package_version, platform_id) 

422 

423 def get_package_config( 

424 self, 

425 package_name: str, 

426 ) -> PackageRemoteConfig: 

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

428 version and fetch information. 

429 """ 

430 

431 # -- Extract package's remote config. 

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

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

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

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

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

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

438 

439 return PackageRemoteConfig( 

440 repo_name=repo_name, 

441 repo_organization=repo_organization, 

442 release_version=release_version, 

443 release_tag=release_tag, 

444 release_file=release_file, 

445 ) 

446 

447 def _load_profile_file(self): 

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

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

450 """ 

451 

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

453 if not self._profile_path.exists(): 

454 return 

455 

456 # -- Read the profile file as a json dict and extract its fields. 

457 # -- Handle invalid content gracefully, e.g. a corrupt or hand 

458 # -- edited file, since this runs on every apio command. 

459 try: 

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

461 data = json.load(f) 

462 _check_json_dict(data, "the file content") 

463 

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

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

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

467 "loaded-by", "" 

468 ) 

469 config_usable = config_apio_version == util.get_apio_version_str() 

470 

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

472 # -- apio version, drop it. 

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

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

475 self._cached_remote_config = remote_config if config_usable else {} 

476 

477 # -- Validate the shape of the extracted fields. 

478 _check_json_dict(self.preferences, "'preferences'") 

479 _check_json_dict(self.installed_packages, "'installed-packages'") 

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

481 _check_json_dict(info, f"package '{name}'") 

482 except (OSError, ValueError, AttributeError) as e: 

483 cerror(f"Invalid profile file {self._profile_path}", f"{e}") 

484 cout( 

485 "You can delete the file and let apio recreate it.", 

486 style=INFO, 

487 ) 

488 sys.exit(1) 

489 

490 def _load_installed_packages_file(self): 

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

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

493 """ 

494 

495 if self._packages_index_path.exists(): 

496 

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

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

499 try: 

500 with open( 

501 self._packages_index_path, "r", encoding="utf8" 

502 ) as f: 

503 self.installed_packages = json.load(f) 

504 _check_json_dict(self.installed_packages, "the file content") 

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

506 _check_json_dict(info, f"package '{name}'") 

507 except (OSError, ValueError) as e: 

508 cerror( 

509 f"Invalid packages index file " 

510 f"{self._packages_index_path}", 

511 f"{e}", 

512 ) 

513 cout( 

514 "You can delete the file and let apio recreate it.", 

515 style=INFO, 

516 ) 

517 sys.exit(1) 

518 

519 def _save(self): 

520 """Save the profile file""" 

521 

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

523 path = self._profile_path.parent 

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

525 path.mkdir() 

526 

527 # -- Construct the json dict. 

528 data = {} 

529 if self.preferences: 

530 data["preferences"] = self.preferences 

531 

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

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

534 

535 # -- Write to profile file. 

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

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

538 

539 # -- Dump 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("Saved profile:", style=EMPH3) 

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

543 

544 def _save_installed_packages(self): 

545 """Save the installed packages file""" 

546 

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

548 path = self._packages_index_path.parent 

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

550 path.mkdir() 

551 

552 # -- Write to profile file. 

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

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

555 

556 # -- Dump for debugging. 

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

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

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

560 

561 def _handle_config_refresh_failure( 

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

563 ): 

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

565 # -- Handle hard error. 

566 if error_is_fatal: 

567 cout(*msg, style=ERROR) 

568 sys.exit(1) 

569 

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

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

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

573 

574 # -- Print the soft warning. 

575 cout(*msg, style=INFO) 

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

577 

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

579 metadata = self._cached_remote_config["metadata"] 

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

581 self._save() 

582 

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

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

585 

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

587 # -- fetch failed. 

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

589 error_is_fatal=error_is_fatal 

590 ) 

591 

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

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

594 # -- wouldn't return with None. 

595 assert not error_is_fatal 

596 return 

597 

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

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

600 cout(config_text) 

601 

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

603 config_text = jsonc.to_json(config_text) 

604 

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

606 try: 

607 remote_config = json.loads(config_text) 

608 

609 # -- Handle parsing error. 

610 except json.decoder.JSONDecodeError as exc: 

611 self._handle_config_refresh_failure( 

612 msg=[ 

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

614 f"{exc}", 

615 ], 

616 error_is_fatal=error_is_fatal, 

617 ) 

618 return 

619 

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

621 # -- check. 

622 ok = self._check_downloaded_remote_config( 

623 remote_config, error_is_fatal=error_is_fatal 

624 ) 

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

626 return 

627 

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

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

630 metadata_dict = {} 

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

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

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

634 remote_config["metadata"] = metadata_dict 

635 

636 self._cached_remote_config = remote_config 

637 self._save() 

638 

639 def _check_downloaded_remote_config( 

640 self, remote_config: Dict, error_is_fatal: bool 

641 ) -> bool: 

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

643 try: 

644 validate(instance=remote_config, schema=REMOTE_CONFIG_SCHEMA) 

645 except ValidationError as e: 

646 # -- Error. 

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

648 self._handle_config_refresh_failure( 

649 msg=msg, error_is_fatal=error_is_fatal 

650 ) 

651 return False 

652 

653 # -- Ok. 

654 return True 

655 

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

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

658 of an error, returns None.""" 

659 

660 # pylint: disable=broad-exception-caught 

661 

662 # -- Announce the remote config url 

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

664 

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

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

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

668 file_path = self.remote_config_url[7:] 

669 try: 

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

671 file_text = f.read() 

672 except Exception as e: 

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

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

675 # -- error instead of a soft error. 

676 self._handle_config_refresh_failure( 

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

678 error_is_fatal=True, 

679 ) 

680 

681 # -- Local file read OK. 

682 return file_text 

683 

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

685 # -- file but at a remote URL. 

686 

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

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

689 try: 

690 resp: requests.Response = requests.get( 

691 self.remote_config_url, timeout=25 

692 ) 

693 error_msg = None 

694 except Exception as e: 

695 error_msg = str(e) 

696 

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

698 # -- them here separately. 

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

700 error_msg = ( 

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

702 ) 

703 

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

705 if error_msg is not None: 

706 self._handle_config_refresh_failure( 

707 msg=[ 

708 "Downloading of the latest Apio remote config " 

709 "file failed.", 

710 error_msg, 

711 ], 

712 error_is_fatal=error_is_fatal, 

713 ) 

714 return None 

715 

716 # -- Done ok. 

717 assert resp.text is not None 

718 return resp.text