Coverage for tests/conftest.py: 96%

205 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-24 03:51 +0000

1"""Pytest TEST configuration file""" 

2 

3import sys 

4import json 

5import subprocess 

6from subprocess import CompletedProcess 

7from dataclasses import dataclass 

8import shutil 

9import tempfile 

10import contextlib 

11from pathlib import Path, PurePosixPath 

12from typing import List, Union, cast, Optional, Dict, Any, Tuple 

13import os 

14from urllib.parse import urlparse 

15from pprint import pprint 

16import pytest 

17from click.testing import CliRunner, Result 

18from apio import __main__ 

19from apio.common import apio_console 

20from apio.common.proto.apio_pb2 import FORCE_PIPE, FORCE_TERMINAL 

21from apio.utils import jsonc 

22 

23 

24# -- Debug mode on/off 

25DEBUG = True 

26 

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

28SANDBOX_MARKER = "apio-sandbox" 

29 

30 

31@dataclass(frozen=True) 

32class ApioResult: 

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

34 

35 exit_code: int 

36 output: str # stdout only 

37 exception: Any 

38 

39 

40class ApioSandbox: 

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

42 ApioRunner sandbox scope.""" 

43 

44 def __init__( 

45 self, 

46 apio_runner_: "ApioRunner", 

47 sandbox_dir: Path, 

48 proj_dir: Path, 

49 home_dir: Path, 

50 packages_dir_: Path, 

51 ): 

52 

53 # pylint: disable=too-many-arguments 

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

55 

56 assert isinstance(sandbox_dir, Path) 

57 assert isinstance(proj_dir, Path) 

58 assert isinstance(home_dir, Path) 

59 assert isinstance(packages_dir_, Path) 

60 

61 self._apio_runner = apio_runner_ 

62 self._sandbox_dir = sandbox_dir 

63 self._proj_dir = proj_dir 

64 self._home_dir = home_dir 

65 self._packages_dir = packages_dir_ 

66 self._click_runner = CliRunner() 

67 

68 @property 

69 def expired(self) -> bool: 

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

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

72 # -- apio runner that creates it. 

73 return self is not self._apio_runner.sandbox 

74 

75 @property 

76 def sandbox_dir(self) -> Path: 

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

78 assert not self.expired, "Sandbox expired" 

79 return self._sandbox_dir 

80 

81 @property 

82 def proj_dir(self) -> Path: 

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

84 assert not self.expired, "Sandbox expired" 

85 return self._proj_dir 

86 

87 @property 

88 def home_dir(self) -> Path: 

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

90 assert not self.expired, "Sandbox expired" 

91 return self._home_dir 

92 

93 @property 

94 def packages_dir(self) -> Path: 

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

96 return self._packages_dir 

97 

98 def clear_packages(self): 

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

100 # -- Sanity check the path and delete. 

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

102 shutil.rmtree(self.packages_dir) 

103 assert not self.packages_dir.exists() 

104 

105 def invoke_apio_cmd( 

106 self, 

107 cli, 

108 args: List[str], 

109 terminal_mode: bool = True, 

110 in_subprocess: bool = False, 

111 ) -> ApioResult: 

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

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

114 

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

116 

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

118 sys.stdout.flush() 

119 sys.stderr.flush() 

120 

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

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

123 

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

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

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

127 

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

129 original_env = os.environ.copy() 

130 

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

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

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

134 

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

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

137 apio_console.configure( 

138 terminal_mode=FORCE_TERMINAL if terminal_mode else FORCE_PIPE, 

139 ) 

140 

141 if in_subprocess: 

142 # -- Invoke apio in a sub process. 

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

144 process_result: CompletedProcess = subprocess.run( 

145 [ 

146 sys.executable, 

147 __main__.__file__, 

148 ] 

149 + args, 

150 capture_output=True, 

151 encoding="utf-8", 

152 text=True, 

153 check=False, 

154 ) 

155 

156 apio_result = ApioResult( 

157 process_result.returncode, 

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

159 None, 

160 ) 

161 

162 else: 

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

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

165 click_result: Result = self._click_runner.invoke( 

166 prog_name="apio", 

167 cli=cli, 

168 args=args, 

169 color=terminal_mode, 

170 ) 

171 

172 # -- Convert click result to apio result. 

173 apio_result = ApioResult( 

174 click_result.exit_code, 

175 click_result.output, 

176 click_result.exception, 

177 ) 

178 

179 # -- Dump to test log. 

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

181 print("result.output:") 

182 print(Result.output) 

183 

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

185 # -- such as PATH. 

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

187 

188 return apio_result 

189 

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

191 # -- lower case. 

192 _DEFAULT_BAD_WORDS = ["error"] 

193 

194 def assert_result_ok( 

195 self, 

196 result: ApioResult, 

197 bad_words: List[str] | Tuple[str, ...] = tuple(_DEFAULT_BAD_WORDS), 

198 ): 

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

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

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

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

203 

204 assert isinstance(result, ApioResult) 

205 

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

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

208 

209 # -- There should be no exceptions raised 

210 assert not result.exception 

211 

212 # -- Check for bad words. 

213 lower_case_output = result.output.lower() 

214 for bad_word in bad_words: 

215 assert bad_word.islower(), bad_word 

216 

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

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

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

220 # -- We exclude that case 

221 if "0 errors" not in lower_case_output: 

222 

223 # -- Check for no errors 

224 assert bad_word not in lower_case_output, bad_word 

225 

226 def restore_system_env( 

227 self, original_env: Dict[str, str], scope: str 

228 ) -> None: 

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

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

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

232 an apio sandbox.""" 

233 

234 # -- Check that the sandbox not expired. 

235 assert not self.expired, "Sandbox expired" 

236 

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

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

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

240 

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

242 

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

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

245 for name in all_var_names: 

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

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

248 original_val = original_env.get(name, None) 

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

250 if current_val != original_val: 

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

252 if original_val is None: 

253 os.environ.pop(name) 

254 else: 

255 os.environ[name] = original_val 

256 

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

258 assert os.environ == original_env 

259 

260 def write_file( 

261 self, 

262 file: Union[str, Path], 

263 text: Union[str, List[str]], 

264 exists_ok=False, 

265 ) -> None: 

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

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

268 

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

270 

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

272 if isinstance(text, list): 

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

274 

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

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

277 

278 # -- Write. 

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

280 f.write(text) 

281 

282 def read_file( 

283 self, file: Union[str, Path], lines_mode=False 

284 ) -> Union[str, List[str]]: 

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

286 lines_mode is True, a list with the individual lines (excluding 

287 the \n delimiters) 

288 """ 

289 

290 # -- Read the text. 

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

292 text = f.read() 

293 

294 # -- Split to lines if requested. 

295 if lines_mode: 

296 text = text.split("\n") 

297 

298 # -- All done 

299 return text 

300 

301 def write_apio_ini( 

302 self, 

303 sections: Dict[str, Dict[str, str]] | None = None, 

304 ): 

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

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

307 

308 assert isinstance(sections, dict) 

309 

310 # -- Construct output file path. 

311 path = Path("apio.ini") 

312 

313 # -- List with text of each section. 

314 sections_texts: List[str] = [] 

315 

316 # -- Add the apio section if specified. 

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

318 lines = [section_header] 

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

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

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

322 

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

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

325 

326 # # -- Write the file. 

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

328 

329 def write_default_apio_ini(self): 

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

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

332 

333 default_apio_ini = { 

334 "[env:default]": { 

335 "board": "alhambra-ii", 

336 "top-module": "main", 

337 } 

338 } 

339 self.write_apio_ini(default_apio_ini) 

340 

341 

342class ApioRunner: 

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

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

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

346 

347 def test_my_cmd(apio_runner): 

348 with apio_runner.in_sandbox() as sb: 

349 

350 <the test body> 

351 """ 

352 

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

354 print("*** creating ApioRunner") 

355 assert isinstance(request, pytest.FixtureRequest) 

356 

357 print("\nOriginal env:") 

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

359 print() 

360 

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

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

363 cache = request.config.cache 

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

365 

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

367 # -- and to invoke apio commands. 

368 self._request = request 

369 

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

371 self._sandbox: ApioSandbox | None = None 

372 

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

374 # -- of the sandboxes. 

375 self._shared_apio_home: Path | None = None 

376 

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

378 # -- apio_runner fixture scope. 

379 request.addfinalizer(self._teardown) 

380 

381 def _teardown(self): 

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

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

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

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

386 shutil.rmtree(self._shared_apio_home) 

387 self._shared_apio_home = None 

388 

389 @staticmethod 

390 def _get_local_config_url() -> str: 

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

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

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

394 the published remote config. 

395 """ 

396 

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

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

399 this_file_path = Path(__file__).resolve() 

400 config_file_path = ( 

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

402 ) 

403 

404 with config_file_path.open(encoding="utf8") as file: 

405 # -- Read the json with comments file 

406 jsonc_text: str = file.read() 

407 

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

409 json_text: str = jsonc.to_json(jsonc_text) 

410 

411 # -- Parse the json format! 

412 json_data: dict = json.loads(json_text) 

413 

414 # -- Get the original remote config url. 

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

416 

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

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

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

420 config_file_path = urlparse(url_str).path 

421 config_file_name = PurePosixPath(config_file_path).name 

422 print(f"Config-file-name = {config_file_name}") 

423 

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

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

426 local_config_file = os.path.normpath( 

427 os.path.join( 

428 os.path.abspath(__file__), 

429 "..", 

430 "..", 

431 "remote-config", 

432 config_file_name, 

433 ) 

434 ) 

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

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

437 

438 return local_config_url 

439 

440 @property 

441 def sandbox(self) -> Optional[ApioSandbox]: 

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

443 return self._sandbox 

444 

445 @contextlib.contextmanager 

446 def in_sandbox(self): 

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

448 and restore the system env upon exist. 

449 

450 Upon return, the current directory is proj_dir. 

451 """ 

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

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

454 

455 # -- Snapshot the system env. 

456 original_env: Dict[str, str] = os.environ.copy() 

457 

458 # -- Snapshot the current directory. 

459 original_cwd = os.getcwd() 

460 

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

462 # -- change to it. 

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

464 

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

466 # -- Initially, we intentionally used a 

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

468 # -- that apio can handle it. 

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

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

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

472 # -- it complains 

473 proj_dir = sandbox_dir / "apio proj" 

474 proj_dir.mkdir() 

475 os.chdir(proj_dir) 

476 

477 # -- Determine the project home dir. 

478 home_dir = sandbox_dir / "apio-home" 

479 

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

481 print() 

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

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

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

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

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

487 print() 

488 

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

490 assert self._sandbox is None 

491 self._sandbox = ApioSandbox( 

492 self, sandbox_dir, proj_dir, home_dir, self._packages_dir 

493 ) 

494 

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

496 # -- home and packages dirs. 

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

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

499 

500 local_config_url = self._get_local_config_url() 

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

502 

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

504 assert os.environ.get("APIO_REMOTE_CONFIG_URL") is None 

505 

506 # Set the URL in the environment 

507 os.environ["APIO_REMOTE_CONFIG_URL"] = local_config_url 

508 

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

510 # -- same process. 

511 apio_console.configure( 

512 terminal_mode=FORCE_TERMINAL, theme_name="light" 

513 ) 

514 

515 try: 

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

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

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

519 yield cast(ApioSandbox, self._sandbox) 

520 

521 finally: 

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

523 # -- exception. 

524 

525 # -- Restore the original system env. 

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

527 

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

529 self._sandbox = None 

530 

531 # -- Change back to the original directory. 

532 os.chdir(original_cwd) 

533 

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

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

536 # -- home. 

537 shutil.rmtree(sandbox_dir) 

538 

539 print("\nSandbox deleted. ") 

540 

541 # -- Flush the output so far. 

542 sys.stdout.flush() 

543 sys.stderr.flush() 

544 

545 

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

547def apio_runner(request): 

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

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

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

551 to reused previously installed packages. 

552 """ 

553 assert isinstance(request, pytest.FixtureRequest) 

554 return ApioRunner(request)