Coverage for tests/conftest.py: 95%
247 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 05:08 +0000
1# This file is part of the Apio project
2# (C) 2016-present FPGAwars
3# Project authors:
4# * Jesús Arroyo (Jesus89) (2016-2019)
5# * Juan Gonzalez (obijuan) (2019-2024)
6# * The Apio Team (2024-present)
7# License: GPLv2
9"""Pytest TEST configuration file"""
11import sys
12import json
13import subprocess
14from subprocess import CompletedProcess
15from dataclasses import dataclass
16from io import StringIO, TextIOBase
17from collections.abc import Iterator
18import shutil
19import tempfile
20import contextlib
21from pathlib import Path
22from typing import cast, Any
23import os
24from pprint import pprint
25import pytest
26from typing_extensions import override
27from click.testing import CliRunner, Result
28from rich.ansi import AnsiDecoder
29from apio import __main__
30from apio.common import apio_console
31from apio.common.proto.stubs.apio_scons_pb2 import FORCE_PIPE, FORCE_TERMINAL
33# -- Debug mode on/off
34DEBUG = True
36# -- This is the marker we use to identify the sandbox directories.
37SANDBOX_MARKER = "apio-sandbox"
40# -- Decoder for removing ansi styles and colors.
41ANSI_DECODER = AnsiDecoder()
44class _LogTee(TextIOBase):
45 """Output stream that writes both to a buffer and to the real
46 std/err stream. Used by the 'with_log' operation."""
48 def __init__(self, log_buffer: StringIO, live_stream: TextIOBase):
49 self._buf = log_buffer
50 self._live = live_stream
52 @override
53 def write(self, s: str) -> int:
54 # -- Write to the log buffer
55 self._buf.write(s)
56 # -- Write to live output.
57 self._live.write(s)
58 self._live.flush()
59 # -- All done.
60 return len(s)
62 @override
63 def flush(self) -> None:
64 self._buf.flush()
65 self._live.flush()
68class CapturedLog:
69 """Holds the captured stdout + stderr."""
71 def __init__(self):
72 self._buf = StringIO()
73 self._output_stream = _LogTee(self._buf, sys.__stdout__)
75 @property
76 def output_stream(self) -> TextIOBase:
77 """Getter to the output stream that sends the output to the logger
78 buffer and to live output."""
79 return self._output_stream
81 @property
82 def buf(self) -> StringIO:
83 """Getter to the capture buffer."""
84 return self._buf
86 @property
87 def out(self) -> str:
88 """Return the full captured text with ansi styles and colors
89 removed."""
90 text_obj = ANSI_DECODER.decode_line(self._buf.getvalue())
91 return text_obj.plain
93 @property
94 def out_styled(self) -> str:
95 """The original output, without the style and color stripping."""
96 return self.out
99@dataclass(frozen=True)
100class ApioResult:
101 """Represent the outcome of an apio invocation."""
103 exit_code: int
104 output: str # stdout only
105 exception: Any
108class ApioSandbox:
109 """Accessor for sandbox values. Available to the user inside an
110 ApioRunner sandbox scope."""
112 def __init__(
113 self,
114 apio_runner_: "ApioRunner",
115 sandbox_dir: Path,
116 proj_dir: Path,
117 home_dir: Path,
118 packages_dir_: Path,
119 ):
121 # pylint: disable=too-many-arguments
122 # pylint: disable=too-many-positional-arguments
124 assert isinstance(sandbox_dir, Path)
125 assert isinstance(proj_dir, Path)
126 assert isinstance(home_dir, Path)
127 assert isinstance(packages_dir_, Path)
129 self._apio_runner = apio_runner_
130 self._sandbox_dir = sandbox_dir
131 self._proj_dir = proj_dir
132 self._home_dir = home_dir
133 self._packages_dir = packages_dir_
134 self._click_runner = CliRunner()
136 @property
137 def expired(self) -> bool:
138 """Returns true if this sandbox was expired."""
139 # -- This tests if this sandbox is still the active sandbox at the
140 # -- apio runner that creates it.
141 return self is not self._apio_runner.sandbox
143 @property
144 def sandbox_dir(self) -> Path:
145 """Returns the sandbox's dir."""
146 assert not self.expired, "Sandbox expired"
147 return self._sandbox_dir
149 @property
150 def proj_dir(self) -> Path:
151 """Returns the sandbox's apio project dir."""
152 assert not self.expired, "Sandbox expired"
153 return self._proj_dir
155 @property
156 def home_dir(self) -> Path:
157 """Returns the sandbox's apio home dir."""
158 assert not self.expired, "Sandbox expired"
159 return self._home_dir
161 @property
162 def packages_dir(self) -> Path:
163 """Returns the sandbox's apio packages dir."""
164 return self._packages_dir
166 def clear_packages(self):
167 """Clear the packages cache, in case a test needs a clean start."""
168 # -- Sanity check the path and delete.
169 assert "packages" in str(self.packages_dir).lower()
170 shutil.rmtree(self.packages_dir)
171 assert not self.packages_dir.exists()
173 def invoke_apio_cmd(
174 self,
175 cli,
176 args: list[str],
177 terminal_mode: bool = True,
178 in_subprocess: bool = False,
179 ) -> ApioResult:
180 """Invoke an apio command. in_subprocess run apios in a subprocess,
181 currently this suppresses colors because of the piping."""
183 print(f"\nInvoking apio command [{cli.name}], args={args}.")
185 # -- It's a good opportunity to flush the output so far.
186 sys.stdout.flush()
187 sys.stderr.flush()
189 # -- Check that this sandbox is still alive.
190 assert not self.expired, "Sandbox expired."
192 # -- Since we restore the env after invoking the apio command, we
193 # -- don't expect path changes by the command to survive here.
194 assert SANDBOX_MARKER not in os.environ["PATH"]
196 # -- Take a snapshot of the system env.
197 original_env = os.environ.copy()
199 # -- These two env vars are set when creating the context. Let's
200 # -- check that the test didn't corrupt them.
201 assert os.environ["APIO_HOME"] == str(self.home_dir)
203 # -- If True, force terminal mode, if False, forces pipe mode,
204 # -- otherwise auto which is pipe mode under pytest.
205 apio_console.configure(
206 terminal_mode=FORCE_TERMINAL if terminal_mode else FORCE_PIPE,
207 )
209 if in_subprocess:
210 # -- Invoke apio in a sub process.
211 print("Invoking apio in a sub process.")
212 process_result: CompletedProcess = subprocess.run(
213 [
214 sys.executable,
215 __main__.__file__,
216 ]
217 + args,
218 capture_output=True,
219 encoding="utf-8",
220 text=True,
221 check=False,
222 )
224 apio_result = ApioResult(
225 process_result.returncode,
226 (process_result.stdout or "") + (process_result.stderr or ""),
227 None,
228 )
230 else:
231 # -- Invoke the command in the same process using click.
232 print("Invoking apio in-process using click")
233 click_result: Result = self._click_runner.invoke(
234 prog_name="apio",
235 cli=cli,
236 args=args,
237 color=terminal_mode,
238 )
240 # -- Convert click result to apio result.
241 apio_result = ApioResult(
242 click_result.exit_code,
243 click_result.output,
244 click_result.exception,
245 )
247 # -- Dump to test log.
248 print(f"result.exit_code:{apio_result.exit_code}")
249 print("result.output:")
250 print(Result.output)
252 # -- Restore system env. Since apio commands tend to change vars
253 # -- such as PATH.
254 self.restore_system_env(original_env, "apio-command")
256 return apio_result
258 # -- List of default bad words for assert_ok(). All words should be
259 # -- lower case.
260 _DEFAULT_BAD_WORDS = ["error"]
262 def assert_result_ok(
263 self,
264 result: ApioResult,
265 bad_words: list[str] | tuple[str, ...] = tuple(_DEFAULT_BAD_WORDS),
266 ):
267 """Check if apio command results where ok. Bad words is an optional
268 list of lower case strings strings if found in the lower case version
269 of the output text, trigger an error. The default is a tuple and not
270 a list to avoid pylint warning about unsafe default value"""
272 assert isinstance(result, ApioResult)
274 # -- It should return an exit code of 0: success
275 assert result.exit_code == 0, result.output
277 # -- There should be no exceptions raised
278 assert not result.exception
280 # -- Check for bad words.
281 lower_case_output = result.output.lower()
282 for bad_word in bad_words:
283 assert bad_word.islower(), bad_word
285 # -- Special case. For Xilinx arch it may be the case that
286 # -- the message contains the string "0 errors". It has the
287 # -- bad word 'error', but it is NOT an error
288 # -- We exclude that case
289 if "0 errors" not in lower_case_output: 289 ↛ 282line 289 didn't jump to line 282 because the condition on line 289 was always true
291 # -- Check for no errors
292 assert bad_word not in lower_case_output, bad_word
294 def restore_system_env(
295 self, original_env: dict[str, str], scope: str
296 ) -> None:
297 """Overwrites the existing sys.environ with the given dict. Vars
298 that are not in the dict are deleted and vars that have a different
299 value in the dict is updated. Can be called only within a
300 an apio sandbox."""
302 # -- Check that the sandbox not expired.
303 assert not self.expired, "Sandbox expired"
305 # -- NOTE: naively assigning the dict to os.environ will break
306 # -- os.environ since a simple dict doesn't update the underlying
307 # -- system env when it's mutated.
309 print(f"\nRestoring os.environ ({scope} scope):")
311 # -- Construct the union of the env and the dict var names.
312 all_var_names = set(os.environ.keys()).union(original_env.keys())
313 for name in all_var_names:
314 # Get the env and dict values. None if doesn't exist.
315 current_val = os.environ.get(name, None)
316 original_val = original_env.get(name, None)
317 # -- If values are not the same, update the env.
318 if current_val != original_val:
319 print(f" set ${name}={original_val} (was {current_val})")
320 if original_val is None: 320 ↛ 323line 320 didn't jump to line 323 because the condition on line 320 was always true
321 os.environ.pop(name)
322 else:
323 os.environ[name] = original_val
325 # -- Sanity check. System env and the dict should be the same.
326 assert os.environ == original_env
328 def write_file(
329 self,
330 file: str | Path,
331 text: str | list[str],
332 exists_ok=False,
333 ) -> None:
334 """Write text to given file. If text is a list, items are joined with
335 "\n". 'file' can be a string or a Path."""
337 assert exists_ok or not Path(file).exists(), f"File exists: {file}"
339 # -- If a list is given, join with '\n"
340 if isinstance(text, list):
341 text = "\n".join(text)
343 # -- Make dir(s) if needed.
344 Path(file).parent.mkdir(parents=True, exist_ok=True)
346 # -- Write.
347 with open(file, "w", encoding="utf-8") as f:
348 f.write(text)
350 def read_file_text(self, file: str | Path) -> str:
351 """Read a text file. Returns a string with the text or if"""
352 with open(file, "r", encoding="utf8") as f:
353 text = f.read()
354 return text
356 def read_file_lines(self, file: str | Path) -> list[str]:
357 """Read a text file. Returns a string split into lines."""
358 text = self.read_file_text(file)
359 text_lines = text.split("\n")
360 return text_lines
362 def write_json_file(
363 self,
364 file: str | Path,
365 json_data: dict[str, Any],
366 exists_ok=False,
367 ):
368 """Write a dict to given json file. 'file' can be a string or a
369 Path."""
370 self.write_file(
371 file, json.dumps(json_data, indent=2), exists_ok=exists_ok
372 )
374 def read_json_file(self, file: str | Path) -> dict[str, Any]:
375 """Read a json file. 'file' can be a string or a Path."""
376 json_text = self.read_file_text(file)
377 json_data = json.loads(json_text)
378 return json_data
380 def write_apio_ini(
381 self,
382 sections: dict[str, dict[str, str]] | None = None,
383 ):
384 """Write in the current directory an apio.ini file with given
385 section. If an apio.ini file already exists, overwrite it."""
387 assert isinstance(sections, dict)
389 # -- Construct output file path.
390 path = Path("apio.ini")
392 # -- List with text of each section.
393 sections_texts: list[str] = []
395 # -- Add the apio section if specified.
396 for section_header, section_options in sections.items():
397 lines = [section_header]
398 for name, value in section_options.items():
399 lines.append(f"{name} = {value}")
400 sections_texts.append("\n".join(lines))
402 # -- Join the sections with a blank line.
403 file_text = "\n\n".join(sections_texts)
405 # # -- Write the file.
406 self.write_file(path, file_text, exists_ok=True)
408 def write_default_apio_ini(self):
409 """Write in the local directory an apio.ini file with default values
410 for testing. If the file exists, it's overwritten."""
412 default_apio_ini = {
413 "[env:default]": {
414 "board": "alhambra-ii",
415 "top-module": "main",
416 }
417 }
418 self.write_apio_ini(default_apio_ini)
421class ApioRunner:
422 """Apio commands test helper. Provides an apio sandbox functionality
423 (disposable temp dirs and sys.environ restoration) as well as a few
424 utility functions. A typical test with the ApiRunner looks like this:
426 def test_my_cmd(apio_runner):
427 with apio_runner.in_sandbox() as sb:
429 <the test body>
430 """
432 def __init__(self, request: pytest.FixtureRequest):
433 print("*** creating ApioRunner")
434 assert isinstance(request, pytest.FixtureRequest)
436 print("\nOriginal env:")
437 pprint(dict(os.environ), width=80, sort_dicts=True)
438 print()
440 # -- Get a pytest directory for the apio packages cache. This will
441 # -- avoid reloading packages by each apio invocation.
442 cache = request.config.cache
443 self._packages_dir = cache.mkdir("apio-cached-packages")
445 # -- A CliRunner instance that is used for creating temp directories
446 # -- and to invoke apio commands.
447 self._request = request
449 # -- Indicate that we are not in a sandbox
450 self._sandbox: ApioSandbox | None = None
452 # -- A placeholder for a shared apio home that we may use in some
453 # -- of the sandboxes.
454 self._shared_apio_home: Path | None = None
456 # -- Register a cleanup method. It's called at the end of the
457 # -- apio_runner fixture scope.
458 request.addfinalizer(self._teardown)
460 def _teardown(self):
461 """Teardown at the end of the apio_runner fixture scope."""
462 if self._shared_apio_home: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 print(f"Deleting apio shared home {str(self._shared_apio_home)}")
464 assert "apio" in str(self._shared_apio_home)
465 shutil.rmtree(self._shared_apio_home)
466 self._shared_apio_home = None
468 @staticmethod
469 def _get_local_config_url() -> str:
470 """Returns a file:/ URL to the remote config file in the local depot.
471 This is used to set APIO_REMOTE_CONFIG_URL for testing to make sure
472 we test with the latest remote config in this change rather than with
473 the published remote config.
474 """
476 # -- Construct the path of the config dir in this repo. We compute it
477 # -- based on the path of this conftest.py python file.
478 local_config_dir = os.path.normpath(
479 os.path.join(
480 os.path.abspath(__file__),
481 "..",
482 "..",
483 "remote-config",
484 )
485 )
486 # -- Convert the file path to a URL with a 'file://' form.
487 local_config_url = "file://" + str(local_config_dir)
489 return local_config_url
491 @contextlib.contextmanager
492 def with_logger(self):
493 """Capture stdout + stderr and yield a CapturedLog object."""
494 print("----- Begin log")
495 log = CapturedLog()
496 with (
497 contextlib.redirect_stdout(log.output_stream),
498 contextlib.redirect_stderr(log.output_stream),
499 ):
500 yield log
501 print("----- End log")
503 @property
504 def sandbox(self) -> ApioSandbox | None:
505 """Returns the sandbox object or None if not in a sandbox."""
506 return self._sandbox
508 @contextlib.contextmanager
509 def in_sandbox(self) -> Iterator[ApioSandbox]:
510 """Create an apio sandbox context manager that delete the temp dir
511 and restore the system env upon exist.
513 Upon return, the current directory is proj_dir.
514 """
515 # -- Make sure we don't try to nest sandboxes.
516 assert self._sandbox is None, "Already in a sandbox."
518 # -- Snapshot the system env.
519 original_env: dict[str, str] = os.environ.copy()
521 # -- Snapshot the current directory.
522 original_cwd = os.getcwd()
524 # -- Create a temp sandbox dir that will be deleted on exit and
525 # -- change to it.
526 sandbox_dir = Path(tempfile.mkdtemp(prefix=SANDBOX_MARKER + "-"))
528 # -- Make the sandbox's project directory.
529 # -- Initially, we intentionally used a
530 # -- directory name with non ascii character to test
531 # -- that apio can handle it.
532 # proj_dir = sandbox_dir / "apio prój"
533 # -- I works ok for the architectures: ice40, ecp5, gowin
534 # -- BUT not for Xilinx. If the path contains a non-ascii character
535 # -- it complains
536 proj_dir = sandbox_dir / "apio proj"
537 proj_dir.mkdir()
538 os.chdir(proj_dir)
540 # -- Determine the project home dir.
541 home_dir = sandbox_dir / "apio-home"
543 if DEBUG: 543 ↛ 553line 543 didn't jump to line 553 because the condition on line 543 was always true
544 print()
545 print("--> apio sandbox:")
546 print(f" sandbox dir : {str(sandbox_dir)}")
547 print(f" apio proj dir : {str(proj_dir)}")
548 print(f" apio home dir : {str(home_dir)}")
549 print(f" apio packages dir : {str(self._packages_dir)}")
550 print()
552 # -- Register a sandbox objet to indicate that we are in a sandbox.
553 assert self._sandbox is None
554 self._sandbox = ApioSandbox(
555 self, sandbox_dir, proj_dir, home_dir, self._packages_dir
556 )
558 # -- Set the system env vars to inform ApioContext what are the
559 # -- home and packages dirs.
560 os.environ["APIO_HOME"] = str(home_dir)
561 os.environ["APIO_PACKAGES"] = str(self._packages_dir)
563 local_config_url = self._get_local_config_url()
564 print(f"Local config url: {local_config_url}")
566 # Sanity check to detect conflicts from prior URL settings.
567 assert (
568 os.environ.get("APIO_REMOTE_CONFIG_URL") is None
569 or os.environ.get("APIO_REMOTE_CONFIG_URL") == local_config_url
570 ), (
571 "A predefined env var APIO_REMOTE_CONFIG_URL conflicts with "
572 "test settings. Update or unset it."
573 )
575 # Set the URL in the environment
576 os.environ["APIO_REMOTE_CONFIG_URL"] = local_config_url
578 # -- Reset the apio console, since we run multiple sandboxes in the
579 # -- same process.
580 apio_console.configure(
581 terminal_mode=FORCE_TERMINAL, theme_name="light"
582 )
584 try:
585 # -- This is the end of the context manager _entry part. The
586 # -- call to _exit will continue execution after the yield.
587 # -- Value is the sandbox object we pass to the user.
588 yield cast(ApioSandbox, self._sandbox)
590 finally:
591 # -- Here when the context manager exit, normally or through an
592 # -- exception.
594 # -- Restore the original system env.
595 self._sandbox.restore_system_env(original_env, "sandbox")
597 # -- Mark that we exited the sandbox. This expires the sandbox.
598 self._sandbox = None
600 # -- Change back to the original directory.
601 os.chdir(original_cwd)
603 # -- Delete the temp directory. This also deletes the apio home
604 # -- if it's not shared but doesn't touch it if we use a shared
605 # -- home.
606 shutil.rmtree(sandbox_dir)
608 print("\nSandbox deleted. ")
610 # -- Flush the output so far.
611 sys.stdout.flush()
612 sys.stderr.flush()
614 def is_on_github_workflow(self) -> bool:
615 """Returns True if running on a github workflow."""
616 # -- NOTE: The env var GITHUB_ACTIONS needs to be whitelisted
617 # -- in tox.ini for it to reach the test. It's a standard env var
618 # -- defined by the github workflows.
619 val = os.environ.get("GITHUB_ACTIONS")
620 return val == "true"
623@pytest.fixture(scope="module")
624def apio_runner(request):
625 """A pytest fixture that provides tests with a ApioRunner test
626 helper object. We use a 'module' scope so sandboxes can share the apio
627 home with other tests in the same file, if we choose to do so,
628 to reused previously installed packages.
629 """
630 assert isinstance(request, pytest.FixtureRequest)
631 return ApioRunner(request)