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