Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ test = [
"filelock>=3.15.1,<4",
"requests",
"requests-cache>=1.2.1,<2",
"portalocker",
]

lint = [
Expand Down
80 changes: 63 additions & 17 deletions tests/json_infra/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import Final, Optional, Set

import git
import portalocker
import pytest
import requests_cache
from _pytest.config import Config
from _pytest.config.argparsing import Parser
Expand Down Expand Up @@ -214,19 +216,8 @@ def __exit__(
fixture_lock = StashKey[Optional[FileLock]]()


def pytest_sessionstart(session: Session) -> None: # noqa: U100
if get_xdist_worker_id(session) != "master":
return

lock_path = session.config.rootpath.joinpath("tests/fixtures/.lock")
stash = session.stash
lock_file = FileLock(str(lock_path), timeout=0)
lock_file.acquire()

assert fixture_lock not in stash
stash[fixture_lock] = lock_file

with _FixturesDownloader(session.config.rootpath) as downloader:
def download_fixtures(root: Path) -> None:
with _FixturesDownloader(root) as downloader:
for _, props in TEST_FIXTURES.items():
fixture_path = props["fixture_path"]

Expand All @@ -243,15 +234,70 @@ def pytest_sessionstart(session: Session) -> None: # noqa: U100
)


def pytest_sessionstart(session: Session) -> None: # noqa: U100
lock_path = session.config.rootpath.joinpath("tests/fixtures/.lock")

# use portalocker for mutmut runs
if os.environ.get("MUTANT_UNDER_TEST"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use the same locking code for both mutmut and non-mutmut runs. The more fine-grained locking isn't going to hurt our normal tests at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tried doing a unified locking - but somehow mutmut tests unusually exits from the point the fixtures are deleted 🤔
can investigate into this more if we want to unify them!

shared_lock = portalocker.Lock(lock_path, flags=portalocker.LOCK_SH)
shared_lock.acquire()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if an exception is thrown while this lock is held? I assume the pytest process eventually exits?

If we aren't sure, we should probably use a try-except that unlocks and unsets the mutmut_shared_lock.

session.stash['mutmut_shared_lock'] = shared_lock

all_fixtures_ready = all(
os.path.exists(props["fixture_path"])
for props in TEST_FIXTURES.values()
)
if not all_fixtures_ready:
shared_lock.release()
with portalocker.Lock(lock_path, flags=portalocker.LOCK_EX):
all_fixtures_ready = all(
os.path.exists(props["fixture_path"])
for props in TEST_FIXTURES.values()
)
if not all_fixtures_ready:
download_fixtures(session.config.rootpath)
shared_lock.acquire()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that between when the exclusive lock is released and when the shared lock is taken that another process acquires the exclusive lock and updates the fixtures to another version?

I think your while True approach where you checked every iteration for the conditions was a good approach.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it! added that back. Also added a try-except.

session.stash['mutmut_shared_lock'] = shared_lock
return

if get_xdist_worker_id(session) != "master":
return

stash = session.stash
lock_file = FileLock(str(lock_path), timeout=0)
lock_file.acquire()

assert fixture_lock not in stash
stash[fixture_lock] = lock_file

download_fixtures(session.config.rootpath)


def pytest_sessionfinish(
session: Session, exitstatus: int # noqa: U100
) -> None:
del exitstatus

if os.environ.get("MUTANT_UNDER_TEST"):
shared_lock = session.stash.get('mutmut_shared_lock', None)
if shared_lock:
shared_lock.release()
return

if get_xdist_worker_id(session) != "master":
return

lock_file = session.stash[fixture_lock]
session.stash[fixture_lock] = None
if fixture_lock in session.stash:
lock_file = session.stash[fixture_lock]
session.stash[fixture_lock] = None

assert lock_file is not None
lock_file.release()


assert lock_file is not None
lock_file.release()
# This is required explicitly becuase when the source does not have any
# mutable code, mutmut does not run the forced fail condition.
@pytest.fixture(autouse=True)
def mutmut_forced_fail() -> None:
if os.environ.get("MUTANT_UNDER_TEST") == "fail":
pytest.fail("Forced fail for mutmut sanity check")
32 changes: 30 additions & 2 deletions tests/json_infra/test_blockchain_tests.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Callable, Dict
from typing import Any, Callable, Dict

import pytest

Expand All @@ -10,13 +10,41 @@
run_blockchain_st_test,
)

# angry mutant cases are tests that cannot be run for mutation testing
ANGRY_MUTANT_CASES = (
"Callcode1024OOG",
"Call1024OOG",
"CallRecursiveBombPreCall",
"CallRecursiveBomb1",
"ABAcalls2",
"CallRecursiveBombLog2",
"CallRecursiveBomb0",
"ABAcalls1",
"CallRecursiveBomb2",
"CallRecursiveBombLog",
)


def is_angry_mutant(test_case: Any) -> bool:
return any(case in str(test_case) for case in ANGRY_MUTANT_CASES)


def get_marked_blockchain_test_cases(fork_name: str) -> list:
"""Get blockchain test cases with angry mutant marking for the given fork."""
return [
pytest.param(tc, marks=pytest.mark.angry_mutant)
if is_angry_mutant(tc)
else tc
for tc in fetch_blockchain_tests(fork_name)
]


def _generate_test_function(fork_name: str) -> Callable:
@pytest.mark.fork(fork_name)
@pytest.mark.json_blockchain_tests
@pytest.mark.parametrize(
"blockchain_test_case",
fetch_blockchain_tests(fork_name),
get_marked_blockchain_test_cases(fork_name),
ids=idfn,
)
def test_func(blockchain_test_case: Dict) -> None:
Expand Down
33 changes: 31 additions & 2 deletions tests/json_infra/test_state_tests.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,47 @@
from typing import Callable, Dict
from typing import Any, Callable, Dict

import pytest

from . import FORKS
from .helpers.load_state_tests import fetch_state_tests, idfn, run_state_test

# angry mutant cases are tests that cannot be run for mutation testing
ANGRY_MUTANT_CASES = (
"Callcode1024OOG",
"Call1024OOG",
"CallRecursiveBombPreCall",
"CallRecursiveBombLog2",
"CallRecursiveBomb2",
"ABAcalls1",
"CallRecursiveBomb0_OOG_atMaxCallDepth",
"ABAcalls2",
"CallRecursiveBomb0",
"CallRecursiveBomb1",
"CallRecursiveBombLog",
)


def is_angry_mutant(test_case: Any) -> bool:
return any(case in str(test_case) for case in ANGRY_MUTANT_CASES)


def get_marked_state_test_cases(fork_name: str) -> list:
"""Get state test cases with angry mutant marking for the given fork."""
return [
pytest.param(tc, marks=pytest.mark.angry_mutant)
if is_angry_mutant(tc)
else tc
for tc in fetch_state_tests(fork_name)
]


def _generate_test_function(fork_name: str) -> Callable:
@pytest.mark.fork(fork_name)
@pytest.mark.evm_tools
@pytest.mark.json_state_tests
@pytest.mark.parametrize(
"state_test_case",
fetch_state_tests(fork_name),
get_marked_state_test_cases(fork_name),
ids=idfn,
)
def test_func(state_test_case: Dict) -> None:
Expand Down
4 changes: 4 additions & 0 deletions vulture_whitelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,7 @@


_children # unused attribute (src/ethereum_spec_tools/docc.py:751)

# tests/conftest.py
# used for mutation testing
mutmut_forced_fail