|
6 | 6 | and writes the generated fixtures to file. |
7 | 7 | """ |
8 | 8 |
|
| 9 | +import atexit |
9 | 10 | import configparser |
10 | 11 | import datetime |
11 | 12 | import json |
12 | 13 | import os |
| 14 | +import signal |
13 | 15 | import warnings |
14 | 16 | from dataclasses import dataclass, field |
15 | 17 | from pathlib import Path |
|
35 | 37 | from execution_testing.client_clis.clis.geth import FixtureConsumerTool |
36 | 38 | from execution_testing.fixtures import ( |
37 | 39 | BaseFixture, |
| 40 | + BlockchainEngineFixture, |
| 41 | + BlockchainFixture, |
38 | 42 | FixtureCollector, |
39 | 43 | FixtureConsumer, |
40 | 44 | FixtureFillingPhase, |
|
43 | 47 | PreAllocGroupBuilder, |
44 | 48 | PreAllocGroupBuilders, |
45 | 49 | PreAllocGroups, |
| 50 | + StateFixture, |
46 | 51 | TestInfo, |
47 | 52 | merge_partial_fixture_files, |
48 | 53 | ) |
|
70 | 75 | ) |
71 | 76 | from .fixture_output import FixtureOutput |
72 | 77 |
|
| 78 | +# Fixture output dir for keyboard interrupt cleanup (set in pytest_configure). |
| 79 | +# Used by _merge_on_exit to merge partial JSONL files on Ctrl+C or SIGTERM. |
| 80 | +_fixture_output_dir: Path | None = None |
| 81 | +_atexit_registered: bool = False |
| 82 | +_interrupt_count: int = 0 |
| 83 | +_original_sigint_handler: Any = None |
| 84 | +_original_sigterm_handler: Any = None |
| 85 | + |
| 86 | + |
| 87 | +def _termination_handler(signum: int, frame: Any) -> None: |
| 88 | + """Handle SIGINT/SIGTERM gracefully during test filling.""" |
| 89 | + del frame |
| 90 | + global _interrupt_count |
| 91 | + global _original_sigint_handler, _original_sigterm_handler |
| 92 | + _interrupt_count += 1 |
| 93 | + |
| 94 | + if _interrupt_count == 1: |
| 95 | + # First interrupt: restore original handlers and re-raise |
| 96 | + if _original_sigint_handler is not None: |
| 97 | + signal.signal(signal.SIGINT, _original_sigint_handler) |
| 98 | + if _original_sigterm_handler is not None: |
| 99 | + signal.signal(signal.SIGTERM, _original_sigterm_handler) |
| 100 | + if signum == signal.SIGTERM: |
| 101 | + raise SystemExit(128 + signum) |
| 102 | + raise KeyboardInterrupt |
| 103 | + # Subsequent interrupts: ignore and print message |
| 104 | + print("\nMerging fixtures, please wait...", flush=True) |
| 105 | + |
| 106 | + |
| 107 | +def _merge_on_exit() -> None: |
| 108 | + """Atexit handler to merge partial JSONL files. Ignores signals.""" |
| 109 | + global _fixture_output_dir |
| 110 | + if _fixture_output_dir is not None: |
| 111 | + signal.signal(signal.SIGINT, signal.SIG_IGN) |
| 112 | + signal.signal(signal.SIGTERM, signal.SIG_IGN) |
| 113 | + merge_partial_fixture_files(_fixture_output_dir) |
| 114 | + # Also merge index if partial indexes exist |
| 115 | + meta_dir = _fixture_output_dir / ".meta" |
| 116 | + if meta_dir.exists() and any(meta_dir.glob("partial_index*.jsonl")): |
| 117 | + merge_partial_indexes(_fixture_output_dir, quiet_mode=True) |
| 118 | + |
73 | 119 |
|
74 | 120 | @dataclass(kw_only=True) |
75 | 121 | class PhaseManager: |
@@ -706,6 +752,22 @@ def pytest_configure(config: pytest.Config) -> None: |
706 | 752 | except ValueError as e: |
707 | 753 | pytest.exit(str(e), returncode=pytest.ExitCode.USAGE_ERROR) |
708 | 754 |
|
| 755 | + # Register atexit/signal handlers for cleanup (master only, not workers). |
| 756 | + global _fixture_output_dir, _atexit_registered |
| 757 | + global _original_sigint_handler, _original_sigterm_handler |
| 758 | + is_xdist_worker = hasattr(config, "workerinput") |
| 759 | + if not config.fixture_output.is_stdout: # type: ignore[attr-defined] |
| 760 | + _fixture_output_dir = config.fixture_output.directory # type: ignore[attr-defined] |
| 761 | + if not _atexit_registered and not is_xdist_worker: |
| 762 | + atexit.register(_merge_on_exit) |
| 763 | + _original_sigint_handler = signal.signal( |
| 764 | + signal.SIGINT, _termination_handler |
| 765 | + ) |
| 766 | + _original_sigterm_handler = signal.signal( |
| 767 | + signal.SIGTERM, _termination_handler |
| 768 | + ) |
| 769 | + _atexit_registered = True |
| 770 | + |
709 | 771 | if ( |
710 | 772 | not config.getoption("disable_html") |
711 | 773 | and config.getoption("htmlpath") is None |
@@ -1047,7 +1109,11 @@ def evm_fixture_verification( |
1047 | 1109 | verify_fixtures_bin = evm_bin |
1048 | 1110 | reused_evm_bin = True |
1049 | 1111 | if not verify_fixtures_bin: |
1050 | | - return |
| 1112 | + pytest.exit( |
| 1113 | + "--verify-fixtures requires --evm-bin or --verify-fixtures-bin " |
| 1114 | + "to be specified.", |
| 1115 | + returncode=pytest.ExitCode.USAGE_ERROR, |
| 1116 | + ) |
1051 | 1117 | try: |
1052 | 1118 | evm_fixture_verification = FixtureConsumerTool.from_binary_path( |
1053 | 1119 | binary_path=Path(verify_fixtures_bin), |
@@ -1241,13 +1307,16 @@ def fixture_collector( |
1241 | 1307 | generate_index=request.config.getoption("generate_index"), |
1242 | 1308 | ) |
1243 | 1309 | yield fixture_collector |
1244 | | - worker_id = os.environ.get("PYTEST_XDIST_WORKER", None) |
1245 | | - fixture_collector.dump_fixtures(worker_id) |
1246 | | - if do_fixture_verification: |
1247 | | - fixture_collector.verify_fixture_files(evm_fixture_verification) |
1248 | | - # Write partial index for this worker/scope |
1249 | | - if fixture_collector.generate_index: |
1250 | | - fixture_collector.write_partial_index(worker_id) |
| 1310 | + try: |
| 1311 | + # dump_fixtures() only needed for stdout mode |
| 1312 | + fixture_collector.dump_fixtures() |
| 1313 | + # Verify fixtures for stdout mode only (files are in memory). |
| 1314 | + # For file mode, verification happens at session finish after merge. |
| 1315 | + if do_fixture_verification and fixture_output.is_stdout: |
| 1316 | + fixture_collector.verify_fixture_files(evm_fixture_verification) |
| 1317 | + finally: |
| 1318 | + # Always close streaming file handles, even on error |
| 1319 | + fixture_collector.close_streaming_files() |
1251 | 1320 |
|
1252 | 1321 |
|
1253 | 1322 | @pytest.fixture(autouse=True, scope="session") |
@@ -1609,6 +1678,65 @@ def pytest_collection_modifyitems( |
1609 | 1678 | items[:] = slow_items + normal_items |
1610 | 1679 |
|
1611 | 1680 |
|
| 1681 | +def _verify_fixtures_post_merge( |
| 1682 | + config: pytest.Config, output_dir: Path |
| 1683 | +) -> None: |
| 1684 | + """ |
| 1685 | + Verify fixtures after merge if verification is enabled. |
| 1686 | +
|
| 1687 | + Called from pytest_sessionfinish after partial files are merged into |
| 1688 | + final JSON fixtures. Runs evm statetest/blocktest on each fixture. |
| 1689 | + """ |
| 1690 | + if not config.getoption("verify_fixtures"): |
| 1691 | + return |
| 1692 | + |
| 1693 | + # Get the verification binary (same logic as evm_fixture_verification) |
| 1694 | + verify_fixtures_bin = config.getoption("verify_fixtures_bin") |
| 1695 | + if not verify_fixtures_bin: |
| 1696 | + verify_fixtures_bin = config.getoption("evm_bin") |
| 1697 | + if not verify_fixtures_bin: |
| 1698 | + return |
| 1699 | + |
| 1700 | + try: |
| 1701 | + evm_verification = FixtureConsumerTool.from_binary_path( |
| 1702 | + binary_path=Path(verify_fixtures_bin), |
| 1703 | + trace=getattr(config, "collect_traces", False), |
| 1704 | + ) |
| 1705 | + except Exception: |
| 1706 | + # Binary not recognized, skip verification (error already shown |
| 1707 | + # during fixture setup if --verify-fixtures was used) |
| 1708 | + return |
| 1709 | + |
| 1710 | + # Map directory names to fixture format classes |
| 1711 | + dir_to_format: dict[str, type[BaseFixture]] = { |
| 1712 | + StateFixture.output_base_dir_name(): StateFixture, |
| 1713 | + BlockchainFixture.output_base_dir_name(): BlockchainFixture, |
| 1714 | + BlockchainEngineFixture.output_base_dir_name(): ( |
| 1715 | + BlockchainEngineFixture |
| 1716 | + ), |
| 1717 | + } |
| 1718 | + |
| 1719 | + # Find all JSON fixture files and verify them |
| 1720 | + for json_file in output_dir.rglob("*.json"): |
| 1721 | + # Determine fixture format from top-level directory |
| 1722 | + relative_path = json_file.relative_to(output_dir) |
| 1723 | + if not relative_path.parts: |
| 1724 | + continue |
| 1725 | + |
| 1726 | + top_dir = relative_path.parts[0] |
| 1727 | + fixture_format = dir_to_format.get(top_dir) |
| 1728 | + if fixture_format is None: |
| 1729 | + continue |
| 1730 | + |
| 1731 | + if evm_verification.can_consume(fixture_format): |
| 1732 | + evm_verification.consume_fixture( |
| 1733 | + fixture_format, |
| 1734 | + json_file, |
| 1735 | + fixture_name=None, |
| 1736 | + debug_output_path=None, |
| 1737 | + ) |
| 1738 | + |
| 1739 | + |
1612 | 1740 | def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: |
1613 | 1741 | """ |
1614 | 1742 | Perform session finish tasks. |
@@ -1656,6 +1784,9 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: |
1656 | 1784 | for file in fixture_output.directory.rglob("*.lock"): |
1657 | 1785 | file.unlink() |
1658 | 1786 |
|
| 1787 | + # Verify fixtures after merge if verification is enabled |
| 1788 | + _verify_fixtures_post_merge(session.config, fixture_output.directory) |
| 1789 | + |
1659 | 1790 | # Generate index file for all produced fixtures by merging partial indexes. |
1660 | 1791 | # Only merge if partial indexes were actually written (i.e., tests produced |
1661 | 1792 | # fixtures). When no tests are filled (e.g., all skipped), no partial |
|
0 commit comments