Coverage for tests/conftest.py: 95%

254 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 03:53 +0000

1"""Pytest TEST configuration file""" 

2 

3import sys 

4import json 

5import subprocess 

6from subprocess import CompletedProcess 

7from dataclasses import dataclass 

8from io import StringIO, TextIOBase 

9from collections.abc import Iterator 

10import shutil 

11import tempfile 

12import contextlib 

13from pathlib import Path, PurePosixPath 

14from typing import cast, Any 

15import os 

16from urllib.parse import urlparse 

17from pprint import pprint 

18import pytest 

19from click.testing import CliRunner, Result 

20from rich.ansi import AnsiDecoder 

21import json5 

22from apio import __main__ 

23from apio.common import apio_console 

24from apio.common.proto.apio_scons_pb2 import FORCE_PIPE, FORCE_TERMINAL 

25 

26# -- Debug mode on/off 

27DEBUG = True 

28 

29# -- This is the marker we use to identify the sandbox directories. 

30SANDBOX_MARKER = "apio-sandbox" 

31 

32 

33# -- Decoder for removing ansi styles and colors. 

34ANSI_DECODER = AnsiDecoder() 

35 

36 

37class _LogTee(TextIOBase): 

38 """Output stream that writes both to a buffer and to the real 

39 std/err stream. Used by the 'with_log' operation.""" 

40 

41 def __init__(self, log_buffer: StringIO, live_stream: TextIOBase): 

42 self._buf = log_buffer 

43 self._live = live_stream 

44 

45 # @overrides 

46 def write(self, s: str) -> int: 

47 # -- Write to the log buffer 

48 self._buf.write(s) 

49 # -- Write to live output. 

50 self._live.write(s) 

51 self._live.flush() 

52 # -- All done. 

53 return len(s) 

54 

55 # @overrides 

56 def flush(self) -> None: 

57 self._buf.flush() 

58 self._live.flush() 

59 

60 

61class CapturedLog: 

62 """Holds the captured stdout + stderr.""" 

63 

64 def __init__(self): 

65 self._buf = StringIO() 

66 self._output_stream = _LogTee(self._buf, sys.__stdout__) 

67 

68 @property 

69 def output_stream(self) -> TextIOBase: 

70 """Getter to the output stream that sends the output to the logger 

71 buffer and to live output.""" 

72 return self._output_stream 

73 

74 @property 

75 def buf(self) -> StringIO: 

76 """Getter to the capture buffer.""" 

77 return self._buf 

78 

79 @property 

80 def out(self) -> str: 

81 """Return the full captured text with ansi styles and colors 

82 removed.""" 

83 text_obj = ANSI_DECODER.decode_line(self._buf.getvalue()) 

84 return text_obj.plain 

85 

86 @property 

87 def out_styled(self) -> str: 

88 """The original output, without the style and color stripping.""" 

89 return self.out 

90 

91 

92@dataclass(frozen=True) 

93class ApioResult: 

94 """Represent the outcome of an apio invocation.""" 

95 

96 exit_code: int 

97 output: str # stdout only 

98 exception: Any 

99 

100 

101class ApioSandbox: 

102 """Accessor for sandbox values. Available to the user inside an 

103 ApioRunner sandbox scope.""" 

104 

105 def __init__( 

106 self, 

107 apio_runner_: "ApioRunner", 

108 sandbox_dir: Path, 

109 proj_dir: Path, 

110 home_dir: Path, 

111 packages_dir_: Path, 

112 ): 

113 

114 # pylint: disable=too-many-arguments 

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

116 

117 assert isinstance(sandbox_dir, Path) 

118 assert isinstance(proj_dir, Path) 

119 assert isinstance(home_dir, Path) 

120 assert isinstance(packages_dir_, Path) 

121 

122 self._apio_runner = apio_runner_ 

123 self._sandbox_dir = sandbox_dir 

124 self._proj_dir = proj_dir 

125 self._home_dir = home_dir 

126 self._packages_dir = packages_dir_ 

127 self._click_runner = CliRunner() 

128 

129 @property 

130 def expired(self) -> bool: 

131 """Returns true if this sandbox was expired.""" 

132 # -- This tests if this sandbox is still the active sandbox at the 

133 # -- apio runner that creates it. 

134 return self is not self._apio_runner.sandbox 

135 

136 @property 

137 def sandbox_dir(self) -> Path: 

138 """Returns the sandbox's dir.""" 

139 assert not self.expired, "Sandbox expired" 

140 return self._sandbox_dir 

141 

142 @property 

143 def proj_dir(self) -> Path: 

144 """Returns the sandbox's apio project dir.""" 

145 assert not self.expired, "Sandbox expired" 

146 return self._proj_dir 

147 

148 @property 

149 def home_dir(self) -> Path: 

150 """Returns the sandbox's apio home dir.""" 

151 assert not self.expired, "Sandbox expired" 

152 return self._home_dir 

153 

154 @property 

155 def packages_dir(self) -> Path: 

156 """Returns the sandbox's apio packages dir.""" 

157 return self._packages_dir 

158 

159 def clear_packages(self): 

160 """Clear the packages cache, in case a test needs a clean start.""" 

161 # -- Sanity check the path and delete. 

162 assert "packages" in str(self.packages_dir).lower() 

163 shutil.rmtree(self.packages_dir) 

164 assert not self.packages_dir.exists() 

165 

166 def invoke_apio_cmd( 

167 self, 

168 cli, 

169 args: list[str], 

170 terminal_mode: bool = True, 

171 in_subprocess: bool = False, 

172 ) -> ApioResult: 

173 """Invoke an apio command. in_subprocess run apios in a subprocess, 

174 currently this suppresses colors because of the piping.""" 

175 

176 print(f"\nInvoking apio command [{cli.name}], args={args}.") 

177 

178 # -- It's a good opportunity to flush the output so far. 

179 sys.stdout.flush() 

180 sys.stderr.flush() 

181 

182 # -- Check that this sandbox is still alive. 

183 assert not self.expired, "Sandbox expired." 

184 

185 # -- Since we restore the env after invoking the apio command, we 

186 # -- don't expect path changes by the command to survive here. 

187 assert SANDBOX_MARKER not in os.environ["PATH"] 

188 

189 # -- Take a snapshot of the system env. 

190 original_env = os.environ.copy() 

191 

192 # -- These two env vars are set when creating the context. Let's 

193 # -- check that the test didn't corrupt them. 

194 assert os.environ["APIO_HOME"] == str(self.home_dir) 

195 

196 # -- If True, force terminal mode, if False, forces pipe mode, 

197 # -- otherwise auto which is pipe mode under pytest. 

198 apio_console.configure( 

199 terminal_mode=FORCE_TERMINAL if terminal_mode else FORCE_PIPE, 

200 ) 

201 

202 if in_subprocess: 

203 # -- Invoke apio in a sub process. 

204 print("Invoking apio in a sub process.") 

205 process_result: CompletedProcess = subprocess.run( 

206 [ 

207 sys.executable, 

208 __main__.__file__, 

209 ] 

210 + args, 

211 capture_output=True, 

212 encoding="utf-8", 

213 text=True, 

214 check=False, 

215 ) 

216 

217 apio_result = ApioResult( 

218 process_result.returncode, 

219 (process_result.stdout or "") + (process_result.stderr or ""), 

220 None, 

221 ) 

222 

223 else: 

224 # -- Invoke the command in the same process using click. 

225 print("Invoking apio in-process using click") 

226 click_result: Result = self._click_runner.invoke( 

227 prog_name="apio", 

228 cli=cli, 

229 args=args, 

230 color=terminal_mode, 

231 ) 

232 

233 # -- Convert click result to apio result. 

234 apio_result = ApioResult( 

235 click_result.exit_code, 

236 click_result.output, 

237 click_result.exception, 

238 ) 

239 

240 # -- Dump to test log. 

241 print(f"result.exit_code:{apio_result.exit_code}") 

242 print("result.output:") 

243 print(Result.output) 

244 

245 # -- Restore system env. Since apio commands tend to change vars 

246 # -- such as PATH. 

247 self.restore_system_env(original_env, "apio-command") 

248 

249 return apio_result 

250 

251 # -- List of default bad words for assert_ok(). All words should be 

252 # -- lower case. 

253 _DEFAULT_BAD_WORDS = ["error"] 

254 

255 def assert_result_ok( 

256 self, 

257 result: ApioResult, 

258 bad_words: list[str] | tuple[str, ...] = tuple(_DEFAULT_BAD_WORDS), 

259 ): 

260 """Check if apio command results where ok. Bad words is an optional 

261 list of lower case strings strings if found in the lower case version 

262 of the output text, trigger an error. The default is a tuple and not 

263 a list to avoid pylint warning about unsafe default value""" 

264 

265 assert isinstance(result, ApioResult) 

266 

267 # -- It should return an exit code of 0: success 

268 assert result.exit_code == 0, result.output 

269 

270 # -- There should be no exceptions raised 

271 assert not result.exception 

272 

273 # -- Check for bad words. 

274 lower_case_output = result.output.lower() 

275 for bad_word in bad_words: 

276 assert bad_word.islower(), bad_word 

277 

278 # -- Special case. For Xilinx arch it may be the case that 

279 # -- the message contains the string "0 errors". It has the 

280 # -- bad word 'error', but it is NOT an error 

281 # -- We exclude that case 

282 if "0 errors" not in lower_case_output: 

283 

284 # -- Check for no errors 

285 assert bad_word not in lower_case_output, bad_word 

286 

287 def restore_system_env( 

288 self, original_env: dict[str, str], scope: str 

289 ) -> None: 

290 """Overwrites the existing sys.environ with the given dict. Vars 

291 that are not in the dict are deleted and vars that have a different 

292 value in the dict is updated. Can be called only within a 

293 an apio sandbox.""" 

294 

295 # -- Check that the sandbox not expired. 

296 assert not self.expired, "Sandbox expired" 

297 

298 # -- NOTE: naively assigning the dict to os.environ will break 

299 # -- os.environ since a simple dict doesn't update the underlying 

300 # -- system env when it's mutated. 

301 

302 print(f"\nRestoring os.environ ({scope} scope):") 

303 

304 # -- Construct the union of the env and the dict var names. 

305 all_var_names = set(os.environ.keys()).union(original_env.keys()) 

306 for name in all_var_names: 

307 # Get the env and dict values. None if doesn't exist. 

308 current_val = os.environ.get(name, None) 

309 original_val = original_env.get(name, None) 

310 # -- If values are not the same, update the env. 

311 if current_val != original_val: 

312 print(f" set ${name}={original_val} (was {current_val})") 

313 if original_val is None: 

314 os.environ.pop(name) 

315 else: 

316 os.environ[name] = original_val 

317 

318 # -- Sanity check. System env and the dict should be the same. 

319 assert os.environ == original_env 

320 

321 def write_file( 

322 self, 

323 file: str | Path, 

324 text: str | list[str], 

325 exists_ok=False, 

326 ) -> None: 

327 """Write text to given file. If text is a list, items are joined with 

328 "\n". 'file' can be a string or a Path.""" 

329 

330 assert exists_ok or not Path(file).exists(), f"File exists: {file}" 

331 

332 # -- If a list is given, join with '\n" 

333 if isinstance(text, list): 

334 text = "\n".join(text) 

335 

336 # -- Make dir(s) if needed. 

337 Path(file).parent.mkdir(parents=True, exist_ok=True) 

338 

339 # -- Write. 

340 with open(file, "w", encoding="utf-8") as f: 

341 f.write(text) 

342 

343 def read_file_text(self, file: str | Path) -> str: 

344 """Read a text file. Returns a string with the text or if""" 

345 with open(file, "r", encoding="utf8") as f: 

346 text = f.read() 

347 return text 

348 

349 def read_file_lines(self, file: str | Path) -> list[str]: 

350 """Read a text file. Returns a string split into lines.""" 

351 text = self.read_file_text(file) 

352 text_lines = text.split("\n") 

353 return text_lines 

354 

355 def write_json_file( 

356 self, 

357 file: str | Path, 

358 json_data: dict[str, dict], 

359 exists_ok=False, 

360 ): 

361 """Write a dict to given json file. 'file' can be a string or a 

362 Path.""" 

363 self.write_file( 

364 file, json.dumps(json_data, indent=2), exists_ok=exists_ok 

365 ) 

366 

367 def read_json_file(self, file: str | Path) -> dict[str, Any]: 

368 """Read a json file. 'file' can be a string or a Path.""" 

369 json_text = self.read_file_text(file) 

370 json_data = json.loads(json_text) 

371 return json_data 

372 

373 def write_apio_ini( 

374 self, 

375 sections: dict[str, dict[str, str]] | None = None, 

376 ): 

377 """Write in the current directory an apio.ini file with given 

378 section. If an apio.ini file already exists, overwrite it.""" 

379 

380 assert isinstance(sections, dict) 

381 

382 # -- Construct output file path. 

383 path = Path("apio.ini") 

384 

385 # -- List with text of each section. 

386 sections_texts: list[str] = [] 

387 

388 # -- Add the apio section if specified. 

389 for section_header, section_options in sections.items(): 

390 lines = [section_header] 

391 for name, value in section_options.items(): 

392 lines.append(f"{name} = {value}") 

393 sections_texts.append("\n".join(lines)) 

394 

395 # -- Join the sections with a blank line. 

396 file_text = "\n\n".join(sections_texts) 

397 

398 # # -- Write the file. 

399 self.write_file(path, file_text, exists_ok=True) 

400 

401 def write_default_apio_ini(self): 

402 """Write in the local directory an apio.ini file with default values 

403 for testing. If the file exists, it's overwritten.""" 

404 

405 default_apio_ini = { 

406 "[env:default]": { 

407 "board": "alhambra-ii", 

408 "top-module": "main", 

409 } 

410 } 

411 self.write_apio_ini(default_apio_ini) 

412 

413 

414class ApioRunner: 

415 """Apio commands test helper. Provides an apio sandbox functionality 

416 (disposable temp dirs and sys.environ restoration) as well as a few 

417 utility functions. A typical test with the ApiRunner looks like this: 

418 

419 def test_my_cmd(apio_runner): 

420 with apio_runner.in_sandbox() as sb: 

421 

422 <the test body> 

423 """ 

424 

425 def __init__(self, request: pytest.FixtureRequest): 

426 print("*** creating ApioRunner") 

427 assert isinstance(request, pytest.FixtureRequest) 

428 

429 print("\nOriginal env:") 

430 pprint(dict(os.environ), width=80, sort_dicts=True) 

431 print() 

432 

433 # -- Get a pytest directory for the apio packages cache. This will 

434 # -- avoid reloading packages by each apio invocation. 

435 cache = request.config.cache 

436 self._packages_dir = cache.mkdir("apio-cached-packages") 

437 

438 # -- A CliRunner instance that is used for creating temp directories 

439 # -- and to invoke apio commands. 

440 self._request = request 

441 

442 # -- Indicate that we are not in a sandbox 

443 self._sandbox: ApioSandbox | None = None 

444 

445 # -- A placeholder for a shared apio home that we may use in some 

446 # -- of the sandboxes. 

447 self._shared_apio_home: Path | None = None 

448 

449 # -- Register a cleanup method. It's called at the end of the 

450 # -- apio_runner fixture scope. 

451 request.addfinalizer(self._teardown) 

452 

453 def _teardown(self): 

454 """Teardown at the end of the apio_runner fixture scope.""" 

455 if self._shared_apio_home: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 print(f"Deleting apio shared home {str(self._shared_apio_home)}") 

457 assert "apio" in str(self._shared_apio_home) 

458 shutil.rmtree(self._shared_apio_home) 

459 self._shared_apio_home = None 

460 

461 @staticmethod 

462 def _get_local_config_url() -> str: 

463 """Returns a file:/ URL to the remote config file in the local depot. 

464 This is used to set APIO_REMOTE_CONFIG_URL for testing to make sure 

465 we test with the latest remote config in this change rather than with 

466 the published remote config. 

467 """ 

468 

469 # -- Read apio/resources/config.jsonc so we can extract the remote 

470 # -- config file name template to construct the local URL. 

471 this_file_path = Path(__file__).resolve() 

472 config_file_path = ( 

473 this_file_path.parent.parent / "apio/resources/config.jsonc" 

474 ) 

475 

476 jsonc_text: str = config_file_path.read_text(encoding="utf-8") 

477 json_data: dict = json5.loads(jsonc_text) 

478 

479 # -- Get the original remote config url. 

480 url_str: str = json_data["remote-config-url"] 

481 

482 # -- Extract the file name part. E.g. 'apio-{major}.{minor}.x.jsonc'. 

483 # -- The {major} and {minor} are placeholders for apio's major and 

484 # -- minor version number which we don't resolve here. 

485 remote_config_file_path: str = urlparse(url_str).path 

486 remote_config_file_name: str = PurePosixPath( 

487 remote_config_file_path 

488 ).name 

489 print(f"remote-config-file-name = {remote_config_file_name}") 

490 

491 # -- Construct the path of the config file in this repo. We compute it 

492 # -- based on the path of this conftest.py python file. 

493 local_config_file = os.path.normpath( 

494 os.path.join( 

495 os.path.abspath(__file__), 

496 "..", 

497 "..", 

498 "remote-config", 

499 remote_config_file_name, 

500 ) 

501 ) 

502 # -- Convert the file path to a URL with a 'file://' form. 

503 local_config_url = "file://" + str(local_config_file) 

504 

505 return local_config_url 

506 

507 @contextlib.contextmanager 

508 def with_logger(self): 

509 """Capture stdout + stderr and yield a CapturedLog object.""" 

510 print("----- Begin log") 

511 log = CapturedLog() 

512 with ( 

513 contextlib.redirect_stdout(log.output_stream), 

514 contextlib.redirect_stderr(log.output_stream), 

515 ): 

516 yield log 

517 print("----- End log") 

518 

519 @property 

520 def sandbox(self) -> ApioSandbox | None: 

521 """Returns the sandbox object or None if not in a sandbox.""" 

522 return self._sandbox 

523 

524 @contextlib.contextmanager 

525 def in_sandbox(self) -> Iterator[ApioSandbox]: 

526 """Create an apio sandbox context manager that delete the temp dir 

527 and restore the system env upon exist. 

528 

529 Upon return, the current directory is proj_dir. 

530 """ 

531 # -- Make sure we don't try to nest sandboxes. 

532 assert self._sandbox is None, "Already in a sandbox." 

533 

534 # -- Snapshot the system env. 

535 original_env: dict[str, str] = os.environ.copy() 

536 

537 # -- Snapshot the current directory. 

538 original_cwd = os.getcwd() 

539 

540 # -- Create a temp sandbox dir that will be deleted on exit and 

541 # -- change to it. 

542 sandbox_dir = Path(tempfile.mkdtemp(prefix=SANDBOX_MARKER + "-")) 

543 

544 # -- Make the sandbox's project directory. 

545 # -- Initially, we intentionally used a 

546 # -- directory name with non ascii character to test 

547 # -- that apio can handle it. 

548 # proj_dir = sandbox_dir / "apio prój" 

549 # -- I works ok for the architectures: ice40, ecp5, gowin 

550 # -- BUT not for Xilinx. If the path contains a non-ascii character 

551 # -- it complains 

552 proj_dir = sandbox_dir / "apio proj" 

553 proj_dir.mkdir() 

554 os.chdir(proj_dir) 

555 

556 # -- Determine the project home dir. 

557 home_dir = sandbox_dir / "apio-home" 

558 

559 if DEBUG: 559 ↛ 569line 559 didn't jump to line 569 because the condition on line 559 was always true

560 print() 

561 print("--> apio sandbox:") 

562 print(f" sandbox dir : {str(sandbox_dir)}") 

563 print(f" apio proj dir : {str(proj_dir)}") 

564 print(f" apio home dir : {str(home_dir)}") 

565 print(f" apio packages dir : {str(self._packages_dir)}") 

566 print() 

567 

568 # -- Register a sandbox objet to indicate that we are in a sandbox. 

569 assert self._sandbox is None 

570 self._sandbox = ApioSandbox( 

571 self, sandbox_dir, proj_dir, home_dir, self._packages_dir 

572 ) 

573 

574 # -- Set the system env vars to inform ApioContext what are the 

575 # -- home and packages dirs. 

576 os.environ["APIO_HOME"] = str(home_dir) 

577 os.environ["APIO_PACKAGES"] = str(self._packages_dir) 

578 

579 local_config_url = self._get_local_config_url() 

580 print(f"Local config url: {local_config_url}") 

581 

582 # Sanity check to detect conflicts from prior URL settings. 

583 assert ( 

584 os.environ.get("APIO_REMOTE_CONFIG_URL") is None 

585 or os.environ.get("APIO_REMOTE_CONFIG_URL") == local_config_url 

586 ), ( 

587 "A predefined env var APIO_REMOTE_CONFIG_URL conflicts with " 

588 "test settings. Update or unset it." 

589 ) 

590 

591 # Set the URL in the environment 

592 os.environ["APIO_REMOTE_CONFIG_URL"] = local_config_url 

593 

594 # -- Reset the apio console, since we run multiple sandboxes in the 

595 # -- same process. 

596 apio_console.configure( 

597 terminal_mode=FORCE_TERMINAL, theme_name="light" 

598 ) 

599 

600 try: 

601 # -- This is the end of the context manager _entry part. The 

602 # -- call to _exit will continue execution after the yield. 

603 # -- Value is the sandbox object we pass to the user. 

604 yield cast(ApioSandbox, self._sandbox) 

605 

606 finally: 

607 # -- Here when the context manager exit, normally or through an 

608 # -- exception. 

609 

610 # -- Restore the original system env. 

611 self._sandbox.restore_system_env(original_env, "sandbox") 

612 

613 # -- Mark that we exited the sandbox. This expires the sandbox. 

614 self._sandbox = None 

615 

616 # -- Change back to the original directory. 

617 os.chdir(original_cwd) 

618 

619 # -- Delete the temp directory. This also deletes the apio home 

620 # -- if it's not shared but doesn't touch it if we use a shared 

621 # -- home. 

622 shutil.rmtree(sandbox_dir) 

623 

624 print("\nSandbox deleted. ") 

625 

626 # -- Flush the output so far. 

627 sys.stdout.flush() 

628 sys.stderr.flush() 

629 

630 def is_on_github_workflow(self) -> bool: 

631 """Returns True if running on a github workflow.""" 

632 # -- NOTE: The env var GITHUB_ACTIONS needs to be whitelisted 

633 # -- in tox.ini for it to reach the test. It's a standard env var 

634 # -- defined by the github workflows. 

635 val = os.environ.get("GITHUB_ACTIONS") 

636 return val == "true" 

637 

638 

639@pytest.fixture(scope="module") 

640def apio_runner(request): 

641 """A pytest fixture that provides tests with a ApioRunner test 

642 helper object. We use a 'module' scope so sandboxes can share the apio 

643 home with other tests in the same file, if we choose to do so, 

644 to reused previously installed packages. 

645 """ 

646 assert isinstance(request, pytest.FixtureRequest) 

647 return ApioRunner(request)