CIRCT 23.0.0git
Loading...
Searching...
No Matches
pytest.py
Go to the documentation of this file.
1# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
2# See https://llvm.org/LICENSE.txt for license information.
3# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4"""Pytest integration for ESI cosimulation tests.
5
6Provides the ``@cosim_test`` decorator which automates the full lifecycle of a
7cosimulation test: running a PyCDE hardware script, compiling the design with
8a simulator (e.g. Verilator), launching the simulator, injecting connection
9parameters into the test function, and tearing everything down afterwards.
10
11Decorated functions run in an isolated child process (via ``fork``) so that
12simulator state never leaks between tests. When applied to a class, the
13hardware compilation is performed once and shared across all ``test_*`` methods.
14
15Typical usage::
16
17 from esiaccel.cosim.pytest import cosim_test
18
19 @cosim_test("path/to/hw_script.py")
20 def test_my_design(conn: AcceleratorConnection):
21 # conn is already connected to the running simulator
22 ...
23"""
24
25from __future__ import annotations
26
27import contextlib
28from dataclasses import dataclass, field, replace
29import functools
30import inspect
31import logging
32import multiprocessing
33import multiprocessing.connection
34import os
35from pathlib import Path
36import re
37import shutil
38import subprocess
39import sys
40import tempfile
41import threading
42import traceback
43from typing import Any, Callable, Dict, Optional, Pattern, Sequence, Union
44
45import esiaccel
46from esiaccel.accelerator import Accelerator, AcceleratorConnection
47
48from .simulator import get_simulator, Simulator, SourceFiles
49
50LogMatcher = Union[str, Pattern[str], Callable[[str, str], bool]]
51SourceGeneratorFunc = Callable[["CosimPytestConfig", Path], Path]
52SourceGeneratorArg = Union[str, Path, SourceGeneratorFunc]
53
54_logger = logging.getLogger("esiaccel.cosim.pytest")
55_DEFAULT_FAILURE_PATTERN = re.compile(r"\berror\b", re.IGNORECASE)
56_DEFAULT_WARN_PATTERN = re.compile(r"\bwarn(ing)?\b", re.IGNORECASE)
57# Default per-test wall-clock timeout in seconds. Matches the 120 s limit
58# used by the lit integration test suite (CIRCT_INTEGRATION_TIMEOUT).
59_DEFAULT_TIMEOUT_S: float = 120.0
60
61
62def _get_env_bool(var_name: str, default: bool) -> bool:
63 """Read a boolean environment variable.
64
65 Args:
66 var_name: Name of the environment variable to read.
67 default: Default value if the variable is not set.
68
69 Returns:
70 The boolean value of the environment variable, or the default value.
71 """
72 value = os.environ.get(var_name)
73 if value is None:
74 return default
75 return value.lower() in ("true", "1", "yes", "on")
76
77
78def _get_env_path(var_name: str) -> Optional[Path]:
79 """Read a path environment variable.
80
81 Args:
82 var_name: Name of the environment variable to read.
83
84 Returns:
85 The path value of the environment variable, or None if not set.
86 """
87 value = os.environ.get(var_name)
88 return Path(value) if value else None
89
90
91def _get_pytest_run_id() -> str:
92 """Get a unique identifier for this pytest invocation.
93
94 For normal runs, uses the current process PID (the pytest process itself).
95 For xdist workers, uses the parent PID (the main pytest controller) so that
96 all workers share the same top-level run directory.
97 """
98 if os.environ.get("PYTEST_XDIST_WORKER"):
99 # Worker process — use parent (the main pytest controller) for grouping.
100 return f"pytest-{os.getppid()}"
101 return f"pytest-{os.getpid()}"
102
103
104def _get_xdist_worker_id() -> Optional[str]:
105 """Get the xdist worker ID if running under pytest-xdist.
106
107 Returns the worker ID (e.g., "gw0", "gw1") or None if not using xdist.
108 """
109 return os.environ.get("PYTEST_XDIST_WORKER")
110
111
112def _get_test_dir_name(config: CosimPytestConfig) -> str:
113 """Build a test directory path including pytest run ID and xdist worker (if present).
114
115 For class-based tests with xdist: "pytest-{pid}/gw{n}/ClassName/test_method".
116 For class-based tests without xdist: "pytest-{pid}/ClassName/test_method".
117 For function tests with xdist: "pytest-{pid}/gw{n}/test_function".
118 For function tests without xdist: "pytest-{pid}/test_function".
119 """
120 parts = []
121
122 if config.pytest_run_id:
123 parts.append(config.pytest_run_id)
124
125 if config.xdist_worker_id:
126 parts.append(config.xdist_worker_id)
127
128 if config.class_name:
129 parts.append(config.class_name)
130
131 if config.test_name:
132 parts.append(config.test_name)
133
134 if not parts:
135 return "unknown-test"
136
137 return "/".join(parts)
138
139
140@dataclass(frozen=True)
142 """Immutable configuration for a single cosim test or test class.
143
144 Attributes:
145 source_generator: Path to the PyCDE hardware generation script, or a
146 callable ``(config, tmp_dir) -> sources_dir``.
147 args: Arguments passed to the script; ``{tmp_dir}`` is interpolated.
148 simulator: Simulator backend name (e.g. ``"verilator"``).
149 top: Top-level module name for the simulator.
150 debug: If True, enable verbose simulator output.
151 timeout_s: Maximum wall-clock seconds before the test is killed.
152 failure_matcher: Pattern applied to simulator output to detect errors.
153 warning_matcher: Pattern applied to simulator output to detect warnings.
154 tmp_dir_root: Root directory for temporary directories. If None, uses system temp.
155 delete_tmp_dir: If True, delete temporary directories after test completion.
156 class_name: Name of the test class (for class-based tests). None for function tests.
157 test_name: Name of the test function or method.
158 pytest_run_id: Unique identifier for the pytest run (e.g., "pytest-12345").
159 xdist_worker_id: ID of the xdist worker if running under pytest-xdist (e.g., "gw0").
160 save_waveform: If True, dump waveform file. Format depends on backend. Requires debug=True.
161 """
162
163 source_generator: SourceGeneratorArg
164 args: Sequence[str] = ("{tmp_dir}",)
165 simulator: str = "verilator"
166 top: str = "ESI_Cosim_Top"
167 debug: bool = False
168 timeout_s: float = _DEFAULT_TIMEOUT_S
169 failure_matcher: Optional[LogMatcher] = _DEFAULT_FAILURE_PATTERN
170 warning_matcher: Optional[LogMatcher] = _DEFAULT_WARN_PATTERN
171 tmp_dir_root: Optional[Path] = None
172 delete_tmp_dir: bool = True
173 class_name: Optional[str] = None
174 test_name: Optional[str] = None
175 pytest_run_id: Optional[str] = None
176 xdist_worker_id: Optional[str] = None
177 save_waveform: bool = False
178
179
180@dataclass
182 """Cached compilation artifacts shared across methods of a test class."""
183
184 sources_dir: Path
185 compile_dir: Path
186
187
188@dataclass
190 """Outcome of a child-process test execution, passed back via queue."""
191
192 success: bool
193 traceback: str = ""
194 failure_lines: Sequence[str] = field(default_factory=list)
195 warning_lines: Sequence[str] = field(default_factory=list)
196 stdout_lines: Sequence[str] = field(default_factory=list)
197 stderr_lines: Sequence[str] = field(default_factory=list)
198
199
200@contextlib.contextmanager
201def _chdir(path: Path):
202 """Context manager that temporarily changes the working directory."""
203 old_cwd = Path.cwd()
204 os.chdir(path)
205 try:
206 yield
207 finally:
208 os.chdir(old_cwd)
209
210
211def _line_matches(matcher: LogMatcher, line: str, stream: str) -> bool:
212 """Return True if *line* matches the given matcher.
213
214 The matcher may be a plain string (regex search), a compiled regex,
215 or a callable ``(line, stream) -> bool``.
216 """
217 if isinstance(matcher, str):
218 return bool(re.search(matcher, line, re.IGNORECASE))
219 elif isinstance(matcher, re.Pattern):
220 return bool(matcher.search(line))
221 else:
222 return matcher(line, stream)
223
224
226 stdout_lines: Sequence[str],
227 stderr_lines: Sequence[str],
228 config: CosimPytestConfig,
229) -> tuple[list[str], list[str]]:
230 """Scan simulator output for failures and warnings.
231
232 Returns:
233 A ``(failures, warnings)`` tuple of tagged log lines.
234 """
235 failures: list[str] = []
236 warnings: list[str] = []
237
238 for stream, lines in (("stdout", stdout_lines), ("stderr", stderr_lines)):
239 for line in lines:
240 tagged = f"[{stream}] {line}"
241 if config.failure_matcher and _line_matches(config.failure_matcher, line,
242 stream):
243 failures.append(tagged)
244 if config.warning_matcher and _line_matches(config.warning_matcher, line,
245 stream):
246 warnings.append(tagged)
247
248 return failures, warnings
249
250
251def _render_args(args: Sequence[str], tmp_dir: Path) -> list[str]:
252 """Interpolate ``{tmp_dir}`` placeholders in script arguments."""
253 return [arg.format(tmp_dir=tmp_dir) for arg in args]
254
255
256def _generate_sources(config: CosimPytestConfig, tmp_dir: Path) -> Path:
257 """Generate hardware sources via a normalized source-generator callable.
258
259 If ``config.source_generator`` is a string/path, it is wrapped into a callable
260 that runs the PyCDE script. If it is already callable, it is used directly.
261 """
262 source_spec = config.source_generator
263 if isinstance(source_spec, (str, Path)):
264 source_generator: SourceGeneratorFunc = (
265 lambda inner_config, inner_tmp_dir: _run_hw_script(
266 source_spec, inner_config, inner_tmp_dir))
267 else:
268 source_generator = source_spec
269 return source_generator(config, tmp_dir)
270
271
272def _create_simulator(config: CosimPytestConfig, sources_dir: Path,
273 run_dir: Path) -> Simulator:
274 """Instantiate a ``Simulator`` from the generated source files."""
275 sources = SourceFiles(config.top)
276 hw_dir = sources_dir / "hw"
277 sources.add_dir(hw_dir if hw_dir.exists() else sources_dir)
278
279 return get_simulator(config.simulator, sources, run_dir, config.debug,
280 config.save_waveform)
281
282
283def _run_hw_script(script_path: Union[str, Path], config: CosimPytestConfig,
284 tmp_dir: Path) -> Path:
285 """Execute the PyCDE hardware script and run codegen if a manifest exists.
286
287 Returns:
288 The directory containing the generated sources (same as *tmp_dir*).
289 """
290 script = Path(script_path).resolve()
291 script_args = _render_args(config.args, tmp_dir)
292 with _chdir(tmp_dir):
293 subprocess.run([sys.executable, str(script), *script_args],
294 check=True,
295 cwd=tmp_dir)
296
297 # Run codegen automatically to generate C++ artifacts from manifest, if present.
298 manifest_path = tmp_dir / "esi_system_manifest.json"
299 if manifest_path.exists():
300 generated_dir = tmp_dir / "generated"
301 generated_dir.mkdir(parents=True, exist_ok=True)
302 try:
303 subprocess.run(
304 [
305 sys.executable, "-m", "esiaccel.codegen", "--file",
306 str(manifest_path), "--output-dir",
307 str(generated_dir)
308 ],
309 check=True,
310 cwd=tmp_dir,
311 )
312 except subprocess.CalledProcessError as e:
313 # Codegen is optional for tests that don't use C++ artifacts
314 _logger.warning("codegen failed (non-fatal): %s", e)
315 return tmp_dir
316
317
318# Names and annotations that the decorator injects automatically.
319_INJECTED_NAMES = {
320 "host", "hostname", "port", "sources_dir", "conn", "accelerator"
321}
322_INJECTED_ANNOTATIONS = frozenset({Accelerator, AcceleratorConnection})
323
324
325def _is_injected_param(name: str, annotation: Any) -> bool:
326 """Return True if *name*/*annotation* will be supplied by the decorator."""
327 return name in _INJECTED_NAMES or annotation in _INJECTED_ANNOTATIONS
328
329
331 target: Callable[..., Any],
332 kwargs: Dict[str, Any],
333 host: str,
334 port: int,
335 sources_dir: Optional[Path] = None,
336) -> Dict[str, Any]:
337 """Build the keyword arguments to inject into the test function.
338
339 Inspects the target's signature and automatically supplies ``host``,
340 ``port``, ``sources_dir``, ``AcceleratorConnection``, or ``Accelerator``
341 parameters that the test declares but the caller did not provide.
342 """
343 sig = inspect.signature(target)
344 updated = dict(kwargs)
345
346 for name, param in sig.parameters.items():
347 if name in updated:
348 continue
349 if name in ("host", "hostname"):
350 updated[name] = host
351 elif name == "port":
352 updated[name] = port
353 elif name == "sources_dir" and sources_dir is not None:
354 updated[name] = sources_dir
355 elif param.annotation is AcceleratorConnection or name == "conn":
356 updated[name] = esiaccel.connect("cosim", f"{host}:{port}")
357 elif param.annotation is Accelerator or name == "accelerator":
358 conn = esiaccel.connect("cosim", f"{host}:{port}")
359 updated[name] = conn.build_accelerator()
360
361 return updated
362
363
364def _visible_signature(target: Callable[..., Any]) -> inspect.Signature:
365 """Return a signature with injected parameters removed.
366
367 Pytest uses function signatures to determine fixture requirements. This
368 hides the parameters that the decorator injects (``host``, ``port``,
369 ``conn``, etc.) so pytest does not try to resolve them as fixtures.
370 Uses :func:`_is_injected_param` as the single source of truth.
371 """
372 sig = inspect.signature(target)
373 kept = [
374 p for p in sig.parameters.values()
375 if not _is_injected_param(p.name, p.annotation)
376 ]
377 return sig.replace(parameters=kept)
378
379
380def _copy_compiled_artifacts(compile_dir: Optional[Path], run_dir: Path):
381 """Copy pre-compiled simulator artifacts into the per-test run directory.
382
383 Copies the *entire* compile directory so that all backends (Verilator,
384 Questa, etc.) find their artefacts regardless of internal layout.
385 """
386 if compile_dir is None:
387 return
388 run_dir.mkdir(parents=True, exist_ok=True)
389 for item in compile_dir.iterdir():
390 dst = run_dir / item.name
391 if item.is_dir():
392 shutil.copytree(item, dst, dirs_exist_ok=True)
393 else:
394 shutil.copy2(item, dst)
395
396
397def _compile_once_for_class(config: CosimPytestConfig) -> _ClassCompileCache:
398 """Run the hw script and compile the simulator once for a whole test class.
399
400 The resulting ``_ClassCompileCache`` is reused by each test method to avoid
401 redundant compilations.
402 """
403 # When using a custom tmp dir, create directories directly under it for easier debugging.
404 # When using the system temp dir, use mkdtemp for automatic isolation.
405 if config.tmp_dir_root is not None:
406 # Organize using hierarchical naming: pytest-{pid}/[gw{n}/]ClassName/__compile__
407 test_dir = _get_test_dir_name(config)
408 compile_root = config.tmp_dir_root / test_dir / "__compile__"
409 compile_root.mkdir(parents=True, exist_ok=True)
410 else:
411 compile_root = Path(tempfile.mkdtemp(prefix="esi-pytest-class-compile-",))
412 try:
413 sources_dir = _generate_sources(config, compile_root)
414 compile_dir = compile_root / "compile"
415 sim = _create_simulator(config, sources_dir, compile_dir)
416 with _chdir(compile_dir):
417 rc = sim.compile()
418 if rc != 0:
419 raise RuntimeError(f"Simulator compile failed with exit code {rc}")
420 return _ClassCompileCache(sources_dir=sources_dir, compile_dir=compile_dir)
421 except Exception:
422 if config.delete_tmp_dir and not config.debug:
423 shutil.rmtree(compile_root, ignore_errors=True)
424 raise
425
426
428 result_pipe: multiprocessing.connection.Connection,
429 target: Callable[..., Any],
430 config: CosimPytestConfig,
431 args: Sequence[Any],
432 kwargs: Dict[str, Any],
433 class_cache: Optional[_ClassCompileCache],
434):
435 """Entry point for the forked child process.
436
437 Compiles (or reuses cached compilation), starts the simulator, injects
438 connection parameters, calls the test function, scans logs for failures,
439 and sends a ``_ChildResult`` through *result_pipe*.
440 """
441 # Keep compile and run output separate. Only run-time output is scanned
442 # by the failure/warning matchers; compile failures are caught via exit code.
443 compile_stdout: list[str] = []
444 compile_stderr: list[str] = []
445 stdout_lines: list[str] = []
446 stderr_lines: list[str] = []
447
448 def on_stdout(line: str):
449 stdout_lines.append(line)
450
451 def on_stderr(line: str):
452 stderr_lines.append(line)
453
454 sim_proc = None
455 sim = None
456 run_root = None
457 run_dir = None
458 try:
459 # When using a custom tmp dir, create directories directly under it for easier debugging.
460 # When using the system temp dir, use mkdtemp for automatic isolation.
461 if config.tmp_dir_root is not None:
462 # Organize under test class and function names for easy identification
463 test_dir = _get_test_dir_name(config)
464 run_root = config.tmp_dir_root / test_dir
465 run_root.mkdir(parents=True, exist_ok=True)
466 else:
467 run_root = Path(tempfile.mkdtemp(prefix="esi-pytest-run-",))
468 if class_cache is None:
469 sources_dir = _generate_sources(config, run_root)
470 run_dir = run_root
471 sim = _create_simulator(config, sources_dir, run_dir)
472 sim._run_stdout_cb = on_stdout
473 sim._run_stderr_cb = on_stderr
474 sim._compile_stdout_cb = compile_stdout.append
475 sim._compile_stderr_cb = compile_stderr.append
476 os.chdir(run_dir)
477 rc = sim.compile()
478 if rc != 0:
479 raise RuntimeError(f"Simulator compile failed with exit code {rc}")
480 else:
481 sources_dir = class_cache.sources_dir
482 run_dir = run_root
483 sim = _create_simulator(config, sources_dir, run_dir)
484 sim._run_stdout_cb = on_stdout
485 sim._run_stderr_cb = on_stderr
486 _copy_compiled_artifacts(class_cache.compile_dir, run_dir)
487
488 os.chdir(run_dir)
489 sim_proc = sim.run_proc()
490 injected_kwargs = _resolve_injected_params(target,
491 kwargs,
492 "localhost",
493 sim_proc.port,
494 sources_dir=sources_dir)
495 target(*args, **injected_kwargs)
496
497 failure_lines, warning_lines = _scan_logs(stdout_lines, stderr_lines,
498 config)
499 if failure_lines:
500 raise AssertionError("Detected simulator failures:\n" +
501 "\n".join(failure_lines))
502
503 # Combine compile + run output for the diagnostic dump, but only
504 # runtime output was fed to the failure/warning matchers above.
505 all_stdout = compile_stdout + stdout_lines
506 all_stderr = compile_stderr + stderr_lines
507 result_pipe.send(
508 _ChildResult(success=True,
509 warning_lines=warning_lines,
510 failure_lines=failure_lines,
511 stdout_lines=all_stdout,
512 stderr_lines=all_stderr))
513 except Exception:
514 all_stdout = compile_stdout + stdout_lines
515 all_stderr = compile_stderr + stderr_lines
516 result_pipe.send(
517 _ChildResult(success=False,
518 traceback=traceback.format_exc(),
519 warning_lines=_scan_logs(stdout_lines, stderr_lines,
520 config)[1],
521 stdout_lines=all_stdout,
522 stderr_lines=all_stderr))
523 finally:
524 result_pipe.close()
525 if sim_proc is not None and sim_proc.proc.poll() is None:
526 sim_proc.force_stop()
527 # When a custom tmp_dir_root is set, keep directories by default for debugging.
528 # When using system temp, delete by default to avoid clutter.
529 should_delete = config.delete_tmp_dir and not config.debug
530 if run_root is not None and should_delete:
531 shutil.rmtree(run_root, ignore_errors=True)
532 # Force-exit the forked child. Non-daemon threads spawned by gRPC (or
533 # other native libraries) can prevent a normal exit even after all Python
534 # work has finished. The result is already in the pipe, so this is safe.
535 os._exit(0)
536
537
539 target: Callable[..., Any],
540 config: CosimPytestConfig,
541 args: Sequence[Any],
542 kwargs: Dict[str, Any],
543 class_cache: Optional[_ClassCompileCache] = None,
544):
545 """Fork a child process to run *target* and wait for its result.
546
547 Handles timeouts, collects warnings, and re-raises any failure from
548 the child as an ``AssertionError`` in the parent.
549 """
550 try:
551 ctx = multiprocessing.get_context("fork")
552 except ValueError:
553 import pytest as _pytest
554 _pytest.skip("fork start method unavailable on this platform")
555 reader, writer = ctx.Pipe(duplex=False)
556 process = ctx.Process(
557 target=_run_child,
558 args=(writer, target, config, args, kwargs, class_cache),
559 )
560 process.start()
561 writer.close() # Parent only reads.
562
563 # Wait for the result with an optional timeout. We poll the pipe first
564 # so that we can detect a child crash even before join() returns.
565 result: Optional[_ChildResult] = None
566 if reader.poll(timeout=config.timeout_s):
567 result = reader.recv()
568 reader.close()
569 process.join(timeout=10)
570 if process.is_alive():
571 process.terminate()
572 process.join(timeout=5)
573
574 if result is None:
575 if config.timeout_s is not None:
576 raise AssertionError(
577 f"Cosim test timed out after {config.timeout_s} seconds")
578 raise RuntimeError(
579 f"Cosim child exited without returning a result (exit code: {process.exitcode})"
580 )
581
582 # Always surface simulation logs for post-mortem debugging.
583 for line in result.stdout_lines:
584 _logger.debug("sim stdout: %s", line)
585 for line in result.stderr_lines:
586 _logger.debug("sim stderr: %s", line)
587 for warning in result.warning_lines:
588 _logger.warning("cosim warning: %s", warning)
589
590 if not result.success:
591 parts = [result.traceback]
592 if result.stdout_lines:
593 parts.append("\n=== Simulator stdout ===")
594 parts.extend(result.stdout_lines[-200:])
595 if result.stderr_lines:
596 parts.append("\n=== Simulator stderr ===")
597 parts.extend(result.stderr_lines[-200:])
598 raise AssertionError("\n".join(parts))
599
600
602 target: Callable[..., Any],
603 config: CosimPytestConfig,
604 class_cache_getter: Optional[Callable[[], _ClassCompileCache]] = None,
605) -> Callable[..., Any]:
606 """Wrap a single test function so it runs inside ``_run_isolated``."""
607 # Set the test name in the config
608 test_config = replace(config, test_name=target.__name__)
609
610 @functools.wraps(target)
611 def _wrapper(*args, **kwargs):
612 cache = class_cache_getter() if class_cache_getter is not None else None
613 _run_isolated(target, test_config, args, kwargs, class_cache=cache)
614
615 setattr(_wrapper, "__signature__", _visible_signature(target))
616 return _wrapper
617
618
619def _decorate_class(target_cls: type, config: CosimPytestConfig) -> type:
620 """Wrap every ``test_*`` method of a class with cosim isolation.
621
622 Compilation is performed once (lazily, on first method invocation) and
623 the resulting artifacts are shared across all methods via a thread-safe
624 cache.
625 """
626 # Set the class name in the config for all methods
627 class_config = replace(config, class_name=target_cls.__name__)
628 lock = threading.Lock()
629 cache_holder: dict[str, _ClassCompileCache] = {}
630
631 def _get_cache() -> _ClassCompileCache:
632 with lock:
633 if "cache" not in cache_holder:
634 cache_holder["cache"] = _compile_once_for_class(class_config)
635 return cache_holder["cache"]
636
637 for name, member in list(vars(target_cls).items()):
638 if name.startswith("test") and callable(member):
639 setattr(
640 target_cls,
641 name,
642 _decorate_function(member,
643 class_config,
644 class_cache_getter=_get_cache),
645 )
646 return target_cls
647
648
649def cosim_test(
650 source_generator: SourceGeneratorArg,
651 args: Sequence[str] = ("{tmp_dir}",),
652 simulator: str = "verilator",
653 top: str = "ESI_Cosim_Top",
654 debug: Optional[bool] = None,
655 timeout_s: float = _DEFAULT_TIMEOUT_S,
656 failure_matcher: Optional[LogMatcher] = _DEFAULT_FAILURE_PATTERN,
657 warning_matcher: Optional[LogMatcher] = _DEFAULT_WARN_PATTERN,
658 tmp_dir_root: Optional[Path] = None,
659 delete_tmp_dir: Optional[bool] = None,
660 save_waveform: Optional[bool] = None,
661):
662 """Decorator that turns a function or class into a cosimulation test.
663
664 The decorated target is executed in a forked child process with a freshly
665 compiled and running simulator. Connection parameters (``host``, ``port``,
666 ``acc``, etc.) are injected automatically based on the function signature.
667
668 When applied to a class, the hardware script is run and compiled once;
669 each ``test_*`` method gets its own simulator process but skips
670 recompilation.
671
672 Args:
673 source_generator: Path to the PyCDE script that generates the hardware, or
674 a callable ``(config, tmp_dir) -> sources_dir`` that generates
675 sources directly.
676 args: Arguments forwarded to the script; ``{tmp_dir}`` is interpolated
677 with the temporary build directory.
678 simulator: Simulator backend (default ``"verilator"``).
679 top: Top-level module name.
680 debug: Enable verbose simulator output. Defaults to the value of the
681 ``ESIACCEL_PYTEST_DEBUG`` environment variable if set, otherwise False.
682 timeout_s: Wall-clock timeout in seconds (default 120).
683 failure_matcher: Pattern to detect errors in simulator output.
684 warning_matcher: Pattern to detect warnings in simulator output.
685 tmp_dir_root: Root directory for temporary test files. Defaults to the value
686 of the ``ESIACCEL_PYTEST_TMP_DIR`` environment variable if set, otherwise
687 uses the system temporary directory. Run directories are organized in a
688 hierarchy to avoid collisions during parallel execution:
689
690 - Normal run: ``pytest-{pid}/ClassName/test_method/`` or
691 ``pytest-{pid}/test_function/``
692 - xdist parallel: ``pytest-{pid}/gw0/ClassName/test_method/`` (gw0, gw1, etc.
693 for different workers)
694 delete_tmp_dir: Whether to delete temporary directories after test
695 completion. When tmp_dir_root is set (custom debugging directory),
696 directories are kept by default; set ``ESIACCEL_PYTEST_DELETE_TMP_DIR=true``
697 to delete them. When using the system temp directory, defaults to True
698 (delete to avoid clutter). Always False when debug mode is enabled.
699 save_waveform: Whether to save waveform dumps (format depends on the
700 simulator backend, e.g. FST for Verilator, VCD for Questa). Requires
701 debug mode to be enabled. Defaults to the value of the
702 ``ESIACCEL_PYTEST_SAVE_WAVEFORM`` environment variable if set, otherwise
703 False.
704 """
705 # Use environment variables as defaults if not explicitly provided
706 if debug is None:
707 debug = _get_env_bool("ESIACCEL_PYTEST_DEBUG", False)
708 if tmp_dir_root is None:
709 tmp_dir_root = _get_env_path("ESIACCEL_PYTEST_TMP_DIR")
710 if delete_tmp_dir is None:
711 # When using a custom tmp_dir_root (provided explicitly or via env),
712 # keep directories by default for debugging. When using the system
713 # temp directory, delete them by default. The env var overrides either.
714 default_delete = tmp_dir_root is None
715 delete_tmp_dir = _get_env_bool("ESIACCEL_PYTEST_DELETE_TMP_DIR",
716 default_delete)
717 if save_waveform is None:
718 save_waveform = _get_env_bool("ESIACCEL_PYTEST_SAVE_WAVEFORM", False)
719
720 # Waveform dumping requires debug mode
721 if save_waveform and not debug:
722 _logger.warning(
723 "save_waveform requires debug mode to be enabled; disabling waveform dumping"
724 )
725 save_waveform = False
726
727 # Get the pytest run ID for unique test isolation
728 pytest_run_id = _get_pytest_run_id()
729 xdist_worker_id = _get_xdist_worker_id()
730
731 config = CosimPytestConfig(
732 source_generator=source_generator,
733 args=args,
734 simulator=simulator,
735 top=top,
736 debug=debug,
737 timeout_s=timeout_s,
738 failure_matcher=failure_matcher,
739 warning_matcher=warning_matcher,
740 tmp_dir_root=tmp_dir_root,
741 delete_tmp_dir=delete_tmp_dir,
742 pytest_run_id=pytest_run_id,
743 xdist_worker_id=xdist_worker_id,
744 save_waveform=save_waveform,
745 )
746
747 def _decorator(target):
748 if inspect.isclass(target):
749 return _decorate_class(target, config)
750 if callable(target):
751 return _decorate_function(target, config)
752 raise TypeError("@cosim_test can decorate functions or classes")
753
754 return _decorator
static mlir::Operation * resolve(Context &context, mlir::SymbolRefAttr sym)
"AcceleratorConnection" connect(str platform, str connection_str)
Definition __init__.py:26
Optional[Path] _get_env_path(str var_name)
Definition pytest.py:78
_copy_compiled_artifacts(Optional[Path] compile_dir, Path run_dir)
Definition pytest.py:380
type _decorate_class(type target_cls, CosimPytestConfig config)
Definition pytest.py:619
_run_isolated(Callable[..., Any] target, CosimPytestConfig config, Sequence[Any] args, Dict[str, Any] kwargs, Optional[_ClassCompileCache] class_cache=None)
Definition pytest.py:544
_run_child(multiprocessing.connection.Connection result_pipe, Callable[..., Any] target, CosimPytestConfig config, Sequence[Any] args, Dict[str, Any] kwargs, Optional[_ClassCompileCache] class_cache)
Definition pytest.py:434
tuple[list[str], list[str]] _scan_logs(Sequence[str] stdout_lines, Sequence[str] stderr_lines, CosimPytestConfig config)
Definition pytest.py:229
Dict[str, Any] _resolve_injected_params(Callable[..., Any] target, Dict[str, Any] kwargs, str host, int port, Optional[Path] sources_dir=None)
Definition pytest.py:336
Path _run_hw_script(Union[str, Path] script_path, CosimPytestConfig config, Path tmp_dir)
Definition pytest.py:284
Path _generate_sources(CosimPytestConfig config, Path tmp_dir)
Definition pytest.py:256
bool _line_matches(LogMatcher matcher, str line, str stream)
Definition pytest.py:211
str _get_test_dir_name(CosimPytestConfig config)
Definition pytest.py:112
Simulator _create_simulator(CosimPytestConfig config, Path sources_dir, Path run_dir)
Definition pytest.py:273
list[str] _render_args(Sequence[str] args, Path tmp_dir)
Definition pytest.py:251
Optional[str] _get_xdist_worker_id()
Definition pytest.py:104
inspect.Signature _visible_signature(Callable[..., Any] target)
Definition pytest.py:364
bool _get_env_bool(str var_name, bool default)
Definition pytest.py:62
str _get_pytest_run_id()
Definition pytest.py:91
bool _is_injected_param(str name, Any annotation)
Definition pytest.py:325
_ClassCompileCache _compile_once_for_class(CosimPytestConfig config)
Definition pytest.py:397
Callable[..., Any] _decorate_function(Callable[..., Any] target, CosimPytestConfig config, Optional[Callable[[], _ClassCompileCache]] class_cache_getter=None)
Definition pytest.py:605
_chdir(Path path)
Definition pytest.py:201