From 083a7c35c6723b8dc14d29da7c711550dc2a0840 Mon Sep 17 00:00:00 2001 From: Faruk Alpay <32020561+farukalpay@users.noreply.github.com> Date: Sun, 24 Aug 2025 17:31:44 +0200 Subject: [PATCH] test: seed global randomness --- src/psd/log_analyzer.py | 7 +++++- tests/conftest.py | 36 +++++++++++++++++++++++++++++++ tests/test_algorithms_property.py | 2 +- tests/test_functions_numerical.py | 8 +++---- tests/test_graph_properties.py | 2 +- tests/test_log_analyzer.py | 22 +++++++++++++++++++ 6 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 tests/conftest.py diff --git a/src/psd/log_analyzer.py b/src/psd/log_analyzer.py index a5a7266..f6d5f9c 100644 --- a/src/psd/log_analyzer.py +++ b/src/psd/log_analyzer.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from pathlib import Path +from .utils import retry + @dataclass class LogStats: @@ -14,13 +16,16 @@ class LogStats: latency_count: int +@retry(OSError, tries=3, delay=0.01) def analyze_log(path: str | Path) -> LogStats: """Summarise errors and latency metrics in a structured log file. Parameters ---------- path: - Path to a log file containing one JSON object per line. + Path to a log file containing one JSON object per line. The function + tolerates transient ``OSError`` issues (for example when a log file is + being rotated) by retrying with a short exponential backoff. """ p = Path(path) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..81950f0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +"""Pytest configuration for deterministic test runs. + +This file seeds all common sources of randomness before each test so that +results are reproducible across runs and on CI. Hypothesis based property +tests are additionally configured with ``derandomize=True`` in the individual +modules. +""" + +from __future__ import annotations + +import os +import random + +import numpy as np +import pytest + +try: # pragma: no cover - optional dependency + import torch +except Exception: # pragma: no cover - torch might not be installed + torch = None # type: ignore[assignment] + + +@pytest.fixture(autouse=True) +def _seed_everything() -> None: + """Seed RNGs for ``random``, ``numpy`` and ``torch`` if available. + + Seeding happens automatically for every test via the ``autouse`` fixture + mechanism. ``PYTHONHASHSEED`` is also set to ensure deterministic hashing + behaviour in dictionaries and other hash based collections. + """ + + os.environ.setdefault("PYTHONHASHSEED", "0") + random.seed(0) + np.random.seed(0) + if torch is not None: + torch.manual_seed(0) diff --git a/tests/test_algorithms_property.py b/tests/test_algorithms_property.py index 4655254..38c0838 100644 --- a/tests/test_algorithms_property.py +++ b/tests/test_algorithms_property.py @@ -9,7 +9,7 @@ from psd.config import PSDConfig -@settings(max_examples=50, deadline=None) +@settings(max_examples=50, deadline=None, derandomize=True) @given( st.lists( st.floats(min_value=-10, max_value=10, allow_nan=False, allow_infinity=False), diff --git a/tests/test_functions_numerical.py b/tests/test_functions_numerical.py index 53077e3..3719d4a 100644 --- a/tests/test_functions_numerical.py +++ b/tests/test_functions_numerical.py @@ -41,7 +41,7 @@ def _finite_diff_hess(g: callable, x: np.ndarray, eps: float = 1e-6) -> np.ndarr return hess -@settings(max_examples=25, deadline=None) +@settings(max_examples=25, deadline=None, derandomize=True) @given(_vector_1d) @pytest.mark.fast def test_separable_quartic_grad_hess_match(x: np.ndarray) -> None: @@ -54,7 +54,7 @@ def test_separable_quartic_grad_hess_match(x: np.ndarray) -> None: np.testing.assert_allclose(h(x), num_hess, rtol=1e-4, atol=1e-4) -@settings(max_examples=20, deadline=None) +@settings(max_examples=20, deadline=None, derandomize=True) @given(_vector_1d_ge2) @pytest.mark.slow def test_rosenbrock_grad_hess_match(x: np.ndarray) -> None: @@ -67,7 +67,7 @@ def test_rosenbrock_grad_hess_match(x: np.ndarray) -> None: np.testing.assert_allclose(h(x), num_hess, rtol=1e-4, atol=1e-4) -@settings(max_examples=20, deadline=None) +@settings(max_examples=20, deadline=None, derandomize=True) @given(_vector_1d, st.integers(min_value=0, max_value=2**32 - 1)) @pytest.mark.fast def test_random_quadratic_determinism_and_derivatives(x: np.ndarray, seed: int) -> None: @@ -94,7 +94,7 @@ def g(z: np.ndarray) -> np.ndarray: np.testing.assert_allclose(functions.random_quadratic_hess(A1), num_hess, rtol=1e-5, atol=1e-6) -@settings(max_examples=5, deadline=None) +@settings(max_examples=5, deadline=None, derandomize=True) @given(st.integers(min_value=0, max_value=2**32 - 1)) @pytest.mark.slow def test_psd_deterministic_given_seed(seed: int) -> None: diff --git a/tests/test_graph_properties.py b/tests/test_graph_properties.py index a5f4a3e..b77e784 100644 --- a/tests/test_graph_properties.py +++ b/tests/test_graph_properties.py @@ -44,7 +44,7 @@ def dfs(node: Hashable, weight: float, path: list[Hashable]) -> None: return best -@settings(max_examples=50, deadline=None) +@settings(max_examples=50, deadline=None, derandomize=True) @given(dag_graphs()) def test_find_optimal_path_matches_bruteforce(data: tuple[Graph, str, str]) -> None: graph, start, end = data diff --git a/tests/test_log_analyzer.py b/tests/test_log_analyzer.py index 4cff292..62ef481 100644 --- a/tests/test_log_analyzer.py +++ b/tests/test_log_analyzer.py @@ -20,3 +20,25 @@ def test_analyze_log(tmp_path: Path) -> None: assert stats.error_count == 1 assert stats.latency_count == 2 assert stats.avg_latency == pytest.approx(0.2) + + +def test_analyze_log_retries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure transient ``OSError`` during file access are retried.""" + + log_file = tmp_path / "psd.log" + log_file.write_text("{}\n") + + calls = {"count": 0} + path_cls = type(log_file) + real_open = path_cls.open + + def flaky_open(self: Path, *args, **kwargs): + if self == log_file and calls["count"] == 0: + calls["count"] += 1 + raise OSError("transient") + return real_open(self, *args, **kwargs) + + monkeypatch.setattr(path_cls, "open", flaky_open) + stats = analyze_log(log_file) + assert stats.error_count == 0 + assert calls["count"] == 1