diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 4597dc1..ec0e324 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -253,9 +253,10 @@ reviews: instructions: >- PASS if the PR is a release preparation (title matches `chore: prepare the * release`), or touches only .github/, benchmarks/, - planning/, test files, or developer tooling configuration that never - reaches the published package (dot-files in the repository root such as - .coderabbit.yaml, .pre-commit-config.yaml, .gitignore). + planning/, CONTRIBUTING.md, test files, or developer tooling + configuration that never reaches the published package (dot-files in + the repository root such as .coderabbit.yaml, .pre-commit-config.yaml, + .gitignore). Otherwise FAIL unless CHANGELOG.md gains at least one bullet under the `## [Unreleased]` heading describing the user-visible effect of the change. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30c4fe7..53d1b29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,7 +137,7 @@ deterministic suite only — the hypothesis suites are excluded, because a test that reaches a branch only sometimes makes both mutmut's test-selection mapping and the score itself irreproducible. -Baseline: **526 of 549 mutants killed (95.8%)**. Every survivor is an equivalent +Baseline: **614 of 637 mutants killed (96.4%)**. Every survivor is an equivalent mutant, in one of five classes: | Class | Why it cannot be killed | diff --git a/pyproject.toml b/pyproject.toml index 4e7ddec..2472ee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -245,16 +245,20 @@ do_not_mutate_patterns = ["cast\\("] # mutmut runs pytest in-process and copies pyproject.toml into `mutants/`, so # the `-n auto` default would fork the collector away from it. pytest_add_cli_args = ["-n", "0", "-p", "no:cacheprovider"] -# Excluded from the mutation suite, for three different reasons: +# Excluded from the mutation suite, for four different reasons: # - the wall-clock example runner and the extras integration tests cost # minutes and exercise third-party code, not the two modules under mutation; # - the hypothesis suites are randomised, and mutmut maps tests to functions # from a single stats run — a test that reaches a branch only sometimes # makes both that mapping and the score irreproducible. The score is a # tracked number, so it is measured against the deterministic suite: a -# survivor then means a missing example-based test, every time. +# survivor then means a missing example-based test, every time; +# - the README link checks read repository files that mutmut does not copy +# into `mutants/`, so collection fails there; they import no library code +# and could not kill a mutant anyway. pytest_add_cli_args_test_selection = [ "tests", + "--ignore=tests/test_readme.py", "--ignore=tests/test_examples.py", "--ignore=tests/test_aiohttp.py", "--ignore=tests/test_fastapi.py", diff --git a/tests/test_coordination.py b/tests/test_coordination.py index fbc7d4e..dc7ae9f 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -8,6 +8,7 @@ import asyncio import gc +import re import threading import weakref from dataclasses import replace @@ -1496,8 +1497,19 @@ def test__breaker__backoff_with_shared_storage__rejected(fake_clock: FakeClock) failed-round count crosses the wire, so a multiplier set here would be read, validated and then quietly ignored — the exact trap the option exists to remove. + + The whole message is asserted because it is the whole remedy: a caller who + hits this has to learn which two ways out exist, and half a sentence would + leave them guessing. """ - with pytest.raises(ValueError, match='wait_duration_backoff_multiplier'): + refusal = ( + 'wait_duration_backoff_multiplier has no effect on a breaker with shared ' + 'storage: reopening is decided by the backend from wait_duration_in_open, ' + 'and no failed-round count is shared. Leave it at 1.0, or drop the storage ' + 'to run this breaker locally. Got 2.0.' + ) + + with pytest.raises(ValueError, match=rf'\A{re.escape(refusal)}\Z'): CircuitBreaker( name='payments', config=Config(wait_duration_backoff_multiplier=2.0), diff --git a/tests/test_engine.py b/tests/test_engine.py index d176702..f85dc97 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -482,6 +482,30 @@ def test__half_open__every_probe_unreachable__reopens( assert engine.state is State.OPEN +def test__half_open__ordinary_exception__stays_a_probe_verdict( + config: Config, fake_clock: FakeClock +) -> None: + """Configuring unreachable exceptions must not turn every failure into one. + + An inconclusive probe hands its slot back and ends no round, so a breaker + that mistook an ordinary failure for one would sit in HALF_OPEN while the + dependency kept failing. + """ + + def boom() -> None: + raise ValueError('boom') + + engine = _unreachable_engine(config, fake_clock) + _trip_to_open(engine) + fake_clock.advance(5.0) + + with pytest.raises(ValueError, match='boom'): + engine.call_sync(boom) + engine.call_sync(lambda: 'ok') + + assert engine.state is State.OPEN + + def test__unreachable_not_configured__probe_reopens_as_before( config: Config, fake_clock: FakeClock ) -> None: @@ -539,7 +563,7 @@ def test__unreachable_exceptions__not_a_tuple__rejected_at_construction( config: Config, fake_clock: FakeClock ) -> None: """``isinstance`` refuses a list of types, so refuse it here with a clear message.""" - with pytest.raises(TypeError, match='must be a tuple'): + with pytest.raises(TypeError, match=r'\Aunreachable_exceptions must be a tuple, got: list\Z'): Engine( name='test', config=config, diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index a4c3778..9c26a8b 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -723,6 +723,10 @@ def test__half_open__probe_round_passes__backoff_resets( _trip_to_open(machine, 2) assert machine.retry_after() == pytest.approx(5.0) + # Counting again from one, not resuming from three: 5 * 2, never 5 * 2**3. + _fail_probe_round(machine, fake_clock, wait=5.0) + assert machine.retry_after() == pytest.approx(10.0) + def test__open__grown_wait__probe_not_admitted_before_it_elapses( config: Config, fake_clock: FakeClock