Skip to content

Commit a9e7eaa

Browse files
committed
Merge branch 'lukaskrodinger/fix-randomness'
* lukaskrodinger/fix-randomness: Patch random module during unpickling to ensure deterministic seeding in subprocesses Refactor test module generation for improved modularity Improve the robustness of random module detection Add tests for deterministic random seeding and random module patching Fixing pre-commit and mypy Track and reseed all SUT-related random.Random instances Patch random.Random.seed for deterministic seeding before SUT import Detect transitive usage of random module in SUT Ensure deterministic random seeding before each test case execution and in generated test modules
2 parents a5489f8 + 3ca2bbd commit a9e7eaa

File tree

8 files changed

+399
-22
lines changed

8 files changed

+399
-22
lines changed

src/pynguin/analyses/module.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,9 @@ def __init__(self, linenos: int) -> None: # noqa: D107
794794
# Keep track of all callables, this is only for statistics purposes.
795795
self.__callables: OrderedSet[GenericCallableAccessibleObject] = OrderedSet()
796796

797+
# Whether the SUT or any of its transitive imports uses Python's random module.
798+
self.sut_uses_random: bool = False
799+
797800
def _setup_generator_selection(self) -> GeneratorProvider:
798801
if (
799802
config.configuration.generator_selection.generator_selection_algorithm

src/pynguin/generator.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
import json
2727
import logging
2828
import math
29+
import random
2930
import sys
31+
import types
3032
from pathlib import Path
3133
from typing import TYPE_CHECKING, cast
3234

@@ -39,6 +41,8 @@
3941
except ImportError:
4042
FANDANGO_FAKER_AVAILABLE = False
4143

44+
import weakref
45+
4246
import pynguin.assertion.assertiongenerator as ag
4347
import pynguin.assertion.llmassertiongenerator as lag
4448
import pynguin.assertion.mutation_analysis.mutators as mu
@@ -240,16 +244,44 @@ def _setup_report_dir() -> bool:
240244
return True
241245

242246

247+
def _patch_random() -> None:
248+
"""Patch random.Random.seed to use the Pynguin seed for determinism.
249+
250+
This must be called BEFORE the SUT is imported, because the SUT (or its
251+
dependencies) may create random.Random instances at module level.
252+
253+
All seeded instances are collected and stored as
254+
``random.Random.seed.__pynguin_instances__`` so callers can reseed every
255+
living SUT-related instance before each test-case execution.
256+
"""
257+
if not getattr(random.Random.seed, "__pynguin_patched__", False):
258+
orig_random_seed = random.Random.seed
259+
tracked: weakref.WeakSet[random.Random] = weakref.WeakSet()
260+
261+
def _deterministic_random_seed(self: random.Random, x=None) -> None:
262+
if x is None:
263+
x = config.configuration.seeding.seed
264+
elif type(x).__hash__ is object.__hash__:
265+
# If x is an object that uses the default id-based hash, we replace it with
266+
# a deterministic string to avoid non-determinism from memory addresses.
267+
x = f"{type(x).__module__}.{type(x).__name__}"
268+
orig_random_seed(self, x)
269+
tracked.add(self)
270+
271+
_deterministic_random_seed.__pynguin_patched__ = True # type: ignore[attr-defined]
272+
_deterministic_random_seed.__pynguin_instances__ = tracked # type: ignore[attr-defined]
273+
random.Random.seed = _deterministic_random_seed # type: ignore[assignment,method-assign]
274+
275+
243276
def _setup_random_number_generator() -> None:
244277
"""Setup RNG."""
245278
_LOGGER.info("Using seed %d", config.configuration.seeding.seed)
246279
randomness.RNG.seed(config.configuration.seeding.seed)
280+
random.seed(config.configuration.seeding.seed)
247281
if config.configuration.pynguinml.ml_testing_enabled:
248282
np_rng.init_rng(config.configuration.seeding.seed)
249283

250284
if FANDANGO_FAKER_AVAILABLE:
251-
# Seed Fandango
252-
random.seed(config.configuration.seeding.seed)
253285
# Seed Faker
254286
Faker.seed(config.configuration.seeding.seed)
255287

@@ -309,6 +341,34 @@ def _verify_config() -> None:
309341
)
310342

311343

344+
def _check_sut_uses_random(new_module_names: set[str]) -> bool:
345+
"""Return True if any module loaded by the SUT import references Python's random.
346+
347+
Args:
348+
new_module_names: Module names that were added to sys.modules as a side
349+
effect of importing the SUT.
350+
351+
Returns:
352+
True if the SUT or any transitive dependency uses Python's random module.
353+
"""
354+
random_module = sys.modules.get("random")
355+
if random_module is None:
356+
return False
357+
358+
if "random" in new_module_names:
359+
return True
360+
361+
for n in new_module_names:
362+
module = sys.modules.get(n)
363+
if module is None or not isinstance(module, types.ModuleType):
364+
continue
365+
for value in vars(module).values():
366+
if value is random_module:
367+
return True
368+
369+
return False
370+
371+
312372
def _setup_and_check() -> tuple[TestCaseExecutor, ModuleTestCluster, ConstantProvider] | None:
313373
"""Load the System Under Test (SUT) i.e. the module that is tested.
314374
@@ -321,15 +381,19 @@ def _setup_and_check() -> tuple[TestCaseExecutor, ModuleTestCluster, ConstantPro
321381
return None
322382
wrapped_constant_provider, dynamic_constant_provider = _setup_constant_seeding()
323383
subject_properties = _setup_import_hook(dynamic_constant_provider)
384+
_patch_random()
385+
modules_before_sut = set(sys.modules.keys())
324386
if not _load_sut(subject_properties):
325387
return None
388+
new_sut_module_names = set(sys.modules.keys()) - modules_before_sut
326389
if not _setup_report_dir():
327390
return None
328391

329392
# Analyzing the SUT should not cause any coverage.
330393
with subject_properties.instrumentation_tracer.temporarily_disable():
331394
if (test_cluster := _setup_test_cluster()) is None:
332395
return None
396+
test_cluster.sut_uses_random = _check_sut_uses_random(new_sut_module_names)
333397

334398
# Make alias to make the following lines shorter...
335399
stop = config.configuration.stopping
@@ -656,7 +720,7 @@ def _run() -> ReturnCode: # noqa: C901
656720
# Export the generated test suites
657721
if config.configuration.test_case_output.export_strategy == config.ExportStrategy.PY_TEST:
658722
try:
659-
_export_chromosome(generation_result)
723+
_export_chromosome(generation_result, sut_uses_random=test_cluster.sut_uses_random)
660724
except Exception as ex:
661725
_LOGGER.exception("Export to PyTest failed: %s", ex)
662726

@@ -1048,13 +1112,17 @@ def _collect_miscellaneous_statistics(test_cluster: ModuleTestCluster) -> None:
10481112
def _export_chromosome(
10491113
chromosome: chrom.Chromosome,
10501114
file_name_suffix: str = "",
1115+
*,
1116+
sut_uses_random: bool = False,
10511117
) -> None:
10521118
"""Export the given chromosome.
10531119
10541120
Args:
10551121
chromosome: the chromosome to export.
10561122
file_name_suffix: Suffix that can be added to the file name to distinguish
10571123
between different results e.g., failing and succeeding test cases.
1124+
sut_uses_random: Whether the SUT transitively uses Python's random module.
1125+
If True, an autouse fixture that reseeds random is emitted.
10581126
10591127
Returns:
10601128
The name of the target file
@@ -1071,6 +1139,7 @@ def _export_chromosome(
10711139
store_call_return=store_call_return,
10721140
no_xfail=config.configuration.test_case_output.no_xfail,
10731141
sut_module_name=config.configuration.module_name,
1142+
pynguin_seed=config.configuration.seeding.seed if sut_uses_random else None,
10741143
)
10751144

10761145
chromosome.accept(export_visitor)

src/pynguin/testcase/execution.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import itertools
1919
import logging
2020
import os
21+
import random
2122
import signal
2223
import sys
2324
import threading
@@ -946,6 +947,25 @@ def execute_multiple(self, test_cases: Iterable[tc.TestCase]) -> Iterable[Execut
946947
yield self.execute(test_case)
947948

948949

950+
def _make_deterministic():
951+
"""Make the execution deterministic.
952+
953+
Reseed the module-level random and every SUT-related random.Random
954+
instance that was tracked by the _patch_random() hook. Pynguin's own
955+
randomness.RNG is excluded so that Pynguin's own decisions remain
956+
unaffected.
957+
"""
958+
seed = config.configuration.seeding.seed
959+
random.seed(seed)
960+
from pynguin.utils import randomness as _rnd # noqa: PLC0415
961+
962+
tracked = getattr(random.Random.seed, "__pynguin_instances__", None)
963+
if tracked is not None:
964+
for _inst in list(tracked):
965+
if _inst is not _rnd.RNG:
966+
_inst.seed(seed)
967+
968+
949969
class TestCaseExecutor(AbstractTestCaseExecutor):
950970
"""An executor that executes the generated test cases."""
951971

@@ -1085,6 +1105,7 @@ def execute( # noqa: D102
10851105
return result
10861106

10871107
def _before_test_case_execution(self, test_case: tc.TestCase) -> None:
1108+
_make_deterministic()
10881109
self._subject_properties.instrumentation_tracer.init_trace()
10891110
for observer in self._yield_remote_observers():
10901111
observer.before_test_case_execution(test_case)
@@ -1246,6 +1267,27 @@ def _after_statement_execution(
12461267
SUPPORTED_EXIT_CODE_MESSAGES[-signal.SIGSEGV] = "Segmentation fault detected"
12471268

12481269

1270+
class PatchRandomOnUnpickle:
1271+
"""A hook that patches random when unpickled in a subprocess.
1272+
1273+
This ensures that random.Random.seed is patched before the SUT is unpickled
1274+
and potentially creates new random.Random instances (e.g., mimesis).
1275+
"""
1276+
1277+
def __init__(self):
1278+
"""Create a new hook."""
1279+
self._config = config.configuration
1280+
1281+
def __getstate__(self):
1282+
return {"config": self._config}
1283+
1284+
def __setstate__(self, state):
1285+
config.configuration = state["config"]
1286+
from pynguin.generator import _patch_random # noqa: PLC0415
1287+
1288+
_patch_random()
1289+
1290+
12491291
class SubprocessTestCaseExecutor(TestCaseExecutor):
12501292
"""An executor that executes the generated test cases in a subprocess."""
12511293

@@ -1451,6 +1493,7 @@ def _setup_subprocess_execution(
14511493
remote_observers = tuple(self._yield_remote_observers())
14521494

14531495
args = (
1496+
PatchRandomOnUnpickle(),
14541497
self._subject_properties,
14551498
self._module_provider,
14561499
self._maximum_test_execution_timeout,
@@ -1702,6 +1745,7 @@ def _fix_assertion_trace(
17021745

17031746
@staticmethod
17041747
def _execute_test_cases_in_subprocess( # noqa: PLR0917
1748+
_patch_random_hook: object,
17051749
subject_properties: SubjectProperties,
17061750
module_provider: ModuleProvider,
17071751
maximum_test_execution_timeout: int,

0 commit comments

Comments
 (0)