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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-03-06

### Added

- `AutoAssertError` — raised immediately when `mark_asserted()` is called while `record()` is in progress; prevents the auto-assert anti-pattern at runtime rather than silently passing tests
- `HttpPlugin.assert_request()` — chained builder returning `HttpAssertionBuilder`; call `.assert_response(status, headers, body)` to assert all 7 HTTP fields in one ergonomic expression
- Named per-step assertion helpers on all state-machine plugin proxies: `socket_mock.assert_connect/send/recv/close`, `db_mock.assert_connect/execute/commit/rollback/close`, `smtp_mock.assert_connect/ehlo/helo/starttls/login/sendmail/send_message/quit`, `popen_mock.assert_spawn/communicate/wait`, `async_websocket_mock.assert_connect/send/recv/close`, `sync_websocket_mock.assert_connect/send/recv/close`
- `redis_mock.assert_command(command, args, kwargs)` — typed helper for Redis command assertions
- `DatabasePlugin` now records a `connect` step when `sqlite3.connect()` is called; initial state changed from `"connected"` to `"disconnected"`

### Changed

- **BREAKING:** `HttpPlugin` interaction fields renamed and expanded: `headers` → `request_headers`, `body` → `request_body`; two new required fields added: `response_headers` and `response_body`. All 7 fields are now required in `assert_interaction()` calls.
- **BREAKING:** `SubprocessPlugin` now requires all fields in assertions: `run` interactions require `command`, `returncode`, `stdout`, `stderr`; `which` interactions require `name` and `returns`.
- **BREAKING:** `RedisPlugin` interactions are no longer auto-asserted; callers must explicitly call `assert_interaction()` or `assert_command()`. All three fields (`command`, `args`, `kwargs`) are now required.
- **BREAKING:** All `StateMachinePlugin` subclasses (Socket, Database, Smtp, Popen, AsyncWebSocket, SyncWebSocket) now use named per-step fields in `interaction.details` instead of generic `{method, args, kwargs}`. Interactions are no longer auto-asserted.
- **BREAKING:** `PopenPlugin` step renamed: `"init"` → `"spawn"`. Stream operations (`stdin.write`, `stdout.read`, `stderr.read`) removed; `_FakeStream.read()` returns `b""`, `write()` returns `0`.
- `BasePlugin.assertable_fields()` changed from `@abstractmethod` to a concrete default returning `frozenset(interaction.details.keys())`. Subclasses may still override for steps with no assertable fields.

### Fixed

- Auto-assert anti-pattern eliminated from `StateMachinePlugin` and `RedisPlugin`: interactions no longer marked asserted at record time; tests that omit `assert_interaction()` calls now correctly fail at `verify_all()`.
- `Timeline.mark_asserted()` raises `AutoAssertError` if called while `record()` is in progress, providing an immediate and actionable error message when plugins attempt to auto-assert.

## [0.4.1] - 2026-03-05

### Added
Expand Down
40 changes: 40 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# bigfoot — Project Instructions

## Certainty is the Contract

bigfoot's entire value proposition is certainty: when a test passes, you **know** exactly what happened — not just that nothing crashed.

### Full Assertion Certainty is Mandatory for All Plugins

Every plugin MUST enforce that all recorded fields are asserted. This is non-negotiable.

**The rule:** `assertable_fields(interaction)` MUST return `frozenset(interaction.details.keys())` minus any fields explicitly excluded for ergonomic reasons (e.g., fields that are already implicit from the source sentinel). The default implementation in `BasePlugin` enforces this.

### Auto-Assert is PROHIBITED

**Auto-asserting interactions is not acceptable under any circumstances.** Auto-assert means calling `timeline.mark_asserted(interaction)` at the time an interaction is *recorded*, before the test author has explicitly called `assert_interaction()`. This defeats the entire purpose of bigfoot.

Do **not** implement auto-assert. Do **not** suggest auto-assert as a design option. Do **not** add it back under any framing ("convenience", "ergonomic default", "opt-in certainty", etc.). It is wrong. It was already removed from StateMachinePlugin and RedisPlugin for this reason.

If you encounter code that calls `timeline.mark_asserted()` inside a `record()` or intercept hook (anywhere other than inside `assert_interaction()`), treat it as a bug and fix it.

The codebase also enforces this at runtime: `Timeline.mark_asserted()` raises `AutoAssertError` immediately if called while `Timeline.record()` is in progress (detected via `ContextVar`). Any test that triggers the auto-assert pattern will fail loudly rather than silently passing. Do not remove or work around this guard.

**What is PROHIBITED:**
- Auto-asserting interactions without requiring explicit `assert_interaction()` calls
- Calling `timeline.mark_asserted(interaction)` from any plugin record/intercept method
- Returning `frozenset()` from `assertable_fields()` without a documented, specific reason
- Recording data in `interaction.details` that callers are not required to assert

**What is REQUIRED:**
- Every field stored in `interaction.details` must be assertable and required by default
- If a field is not meaningful to assert (e.g., internal metadata), exclude it from `details` entirely — do not record it and then silently skip it
- New plugins must use `frozenset(interaction.details.keys())` as their `assertable_fields` implementation unless there is a specific, documented ergonomic reason to exclude a field

### Ergonomic Assertion Helpers are Encouraged

Plugins may (and should) provide typed assertion helper methods on their proxy objects to make asserting common patterns ergonomic. These helpers MUST still enforce full field coverage — they are wrappers around `assert_interaction()`, not bypasses.

### The Test of Certainty

Ask yourself: if every `assert_interaction()` call in a test were removed, would the test still pass? If yes, the plugin is not providing certainty. Fix it.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,22 @@ def test_payment_flow():
response = httpx.post("https://api.stripe.com/v1/charges",
json={"amount": 5000})

# Option A: explicit assert_interaction (all 7 fields required)
bigfoot.assert_interaction(
bigfoot.http.request,
method="POST",
url="https://api.stripe.com/v1/charges",
headers=IsMapping(), # use dirty-equals or ANY for headers
body=None,
request_headers=IsMapping(), # use dirty-equals or ANY for headers
request_body=None,
status=200,
response_headers=IsMapping(),
response_body=IsMapping() | IsInstance(str),
)
# Option B: chained helper
bigfoot.http.assert_request(
method="POST", url="https://api.stripe.com/v1/charges",
headers=IsMapping(), body=None,
).assert_response(status=200, headers=IsMapping(), body=IsMapping() | IsInstance(str))
assert response.json()["id"] == "ch_123"
# verify_all() called automatically at test teardown
```
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "bigfoot"
version = "0.4.1"
version = "0.5.0"
description = "A pluggable interaction auditor for Python tests."
requires-python = ">=3.11"
readme = "README.md"
Expand Down
2 changes: 2 additions & 0 deletions src/bigfoot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from bigfoot._context import _get_test_verifier_or_raise
from bigfoot._errors import (
AssertionInsideSandboxError,
AutoAssertError,
BigfootError,
ConflictError,
InteractionMismatchError,
Expand Down Expand Up @@ -72,6 +73,7 @@
# Errors
"BigfootError",
"AssertionInsideSandboxError",
"AutoAssertError",
"InvalidStateError",
"NoActiveVerifierError",
"UnmockedInteractionError",
Expand Down
21 changes: 16 additions & 5 deletions src/bigfoot/_base_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any

from bigfoot._recording import _recording_in_progress

if TYPE_CHECKING:
from bigfoot._timeline import Interaction
from bigfoot._verifier import StrictVerifier
Expand Down Expand Up @@ -58,15 +60,20 @@ def format_unmocked_hint(
def format_assert_hint(self, interaction: "Interaction") -> str:
"""Copy-pasteable code to assert this interaction (resolves UnassertedInteractionsError)."""

@abstractmethod
def assertable_fields(self, interaction: "Interaction") -> frozenset[str]:
"""Return the set of field names that must appear in **expected when asserting
this interaction.

Default implementation: return all keys in interaction.details as assertable
fields. This is correct for any plugin that stores only user-assertable data
in details. Plugins with no-data steps (close, commit, etc.) must override
this to return frozenset() for those steps.

The verifier calls this after matching by source_id to enforce completeness:
any field in the returned set that is absent from **expected causes
MissingAssertionFieldsError to be raised.
"""
return frozenset(interaction.details.keys())

@abstractmethod
def get_unused_mocks(self) -> list[Any]:
Expand All @@ -79,8 +86,12 @@ def format_unused_mock_hint(self, mock_config: object) -> str:
def record(self, interaction: "Interaction") -> None:
"""Concrete method: append interaction to the verifier's shared timeline.

This is NOT abstract -- all plugins share the same implementation.
Calls self.verifier._timeline.append(interaction), which assigns the
sequence number atomically under the timeline lock.
Sets _recording_in_progress for the duration of the append so that
Timeline.mark_asserted() can detect the auto-assert anti-pattern and
raise AutoAssertError immediately.
"""
self.verifier._timeline.append(interaction)
token = _recording_in_progress.set(True)
try:
self.verifier._timeline.append(interaction)
finally:
_recording_in_progress.reset(token)
9 changes: 9 additions & 0 deletions src/bigfoot/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ def __init__(self, missing_fields: frozenset[str]) -> None:
)


class AutoAssertError(BigfootError):
"""Raised when mark_asserted() is called while record() is in progress.

This indicates the auto-assert anti-pattern: a plugin calling
timeline.mark_asserted() immediately after record() inside its intercept
hook, bypassing the requirement for explicit test assertions.
"""


class InvalidStateError(BigfootError):
"""Raised when a state-machine method is called from an invalid state.

Expand Down
12 changes: 12 additions & 0 deletions src/bigfoot/_recording.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Shared ContextVar for the auto-assert runtime guard.

Imported by both _base_plugin and _timeline to avoid a circular import:
_base_plugin imports _timeline (for Interaction type), so _timeline
cannot import from _base_plugin. Both import from this module instead.
"""

from contextvars import ContextVar

_recording_in_progress: ContextVar[bool] = ContextVar(
"_recording_in_progress", default=False
)
66 changes: 56 additions & 10 deletions src/bigfoot/_state_machine_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ class ScriptStep:
registration_traceback: str = field(default_factory=lambda: "".join(traceback.format_stack()))


# ---------------------------------------------------------------------------
# _StepSentinel
# ---------------------------------------------------------------------------


class _StepSentinel:
"""Opaque handle representing a specific state-machine step.

Used as the source filter argument in assert_interaction() calls.
Each step (connect, send, recv, etc.) has its own sentinel instance
on the plugin, accessible as a property.

Attributes:
source_id: The string source_id recorded in Interaction objects
for this step.
"""

def __init__(self, source_id: str) -> None:
self.source_id = source_id


# ---------------------------------------------------------------------------
# SessionHandle
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -227,16 +248,28 @@ def _execute_step(
args: tuple[Any, ...],
kwargs: dict[str, Any],
source_id: str,
details: dict[str, Any] | None = None,
*,
return_interaction: bool = False,
) -> Any: # noqa: ANN401
"""Execute the next script step for the given handle and method.

Steps:
1. Validate that method is allowed from the current state.
2. Pop the next ScriptStep (FIFO).
3. Advance handle._state.
4. Record the Interaction on the timeline and immediately mark it asserted.
4. Record the Interaction on the timeline (NOT auto-asserted).
5. If step.raises is set, raise it; otherwise return step.returns.

Args:
details: Named fields dict to store in the Interaction. When None,
falls back to the legacy format {"method": method, "args": args,
"kwargs": kwargs}. All concrete plugins in this release pass
explicit dicts.
return_interaction: When True, returns (result, interaction) tuple
instead of just result. Used by recv() implementations that need
to update interaction.details["data"] after the step executes.

Raises:
InvalidStateError: If the current state is not a valid from-state
for this method, or if the method is not in _transitions().
Expand Down Expand Up @@ -280,32 +313,45 @@ def _execute_step(
# Advance state
handle._state = method_transitions[handle._state]

# Record interaction and immediately mark asserted
# Build details dict — use caller-supplied named fields or legacy fallback
resolved_details: dict[str, Any] = (
details if details is not None
else {"method": method, "args": args, "kwargs": kwargs}
)

# Record interaction on the timeline — test authors must assert explicitly
interaction = Interaction(
source_id=source_id,
sequence=0,
details={"method": method, "args": args, "kwargs": kwargs},
details=resolved_details,
plugin=self,
)
self.record(interaction)
self.verifier._timeline.mark_asserted(interaction)
# No mark_asserted() — auto-assert anti-pattern is prohibited

# Execute step
if step.raises is not None:
raise step.raises
return step.returns

result = step.returns
if return_interaction:
return result, interaction
return result

# ------------------------------------------------------------------
# BasePlugin: overridden concrete methods
# ------------------------------------------------------------------

def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool:
"""Always returns True — state machine interactions are auto-matched."""
return True
"""Placeholder — each concrete StateMachine plugin task (5–12) overrides this.

def assertable_fields(self, interaction: Interaction) -> frozenset[str]:
"""Returns empty frozenset — no fields are required in assert_interaction()."""
return frozenset()
Retained here so all concrete subclasses remain instantiable during the
transition period before each per-plugin task provides a typed override.
BasePlugin.matches() is abstract; this placeholder satisfies that
requirement at the StateMachinePlugin level until every concrete class
defines its own implementation.
"""
return True

# ------------------------------------------------------------------
# BasePlugin: get_unused_mocks
Expand Down
13 changes: 13 additions & 0 deletions src/bigfoot/_timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from bigfoot._recording import _recording_in_progress

if TYPE_CHECKING:
from bigfoot._base_plugin import BasePlugin

Expand Down Expand Up @@ -54,6 +56,17 @@ def find_any_unasserted(
return None

def mark_asserted(self, interaction: Interaction) -> None:
from bigfoot._errors import (
AutoAssertError, # noqa: PLC0415 — avoids circular import at module level
)

if _recording_in_progress.get():
raise AutoAssertError(
f"mark_asserted() was called while record() is in progress for "
f"source_id={interaction.source_id!r}. This is the auto-assert "
f"anti-pattern: remove the mark_asserted() call from your plugin's "
f"intercept hook. Test authors must call assert_interaction() explicitly."
)
with self._lock:
interaction._asserted = True

Expand Down
Loading