Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion src/psd/log_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from dataclasses import dataclass
from pathlib import Path

from .utils import retry


@dataclass
class LogStats:
Expand All @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion tests/test_algorithms_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions tests/test_functions_numerical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_graph_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions tests/test_log_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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