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