Writing Plugins¶
bigfoot's plugin system allows you to add interception for any type of interaction, not just HTTP or method calls. Custom plugins follow the BasePlugin abstract base class.
BasePlugin contract¶
All plugins must subclass BasePlugin and implement ten abstract methods. The __init__ method must call super().__init__(verifier), which registers the plugin with the verifier.
from bigfoot._base_plugin import BasePlugin
from bigfoot._timeline import Interaction
from bigfoot._verifier import StrictVerifier
Abstract methods¶
activate()¶
Called when the sandbox is entered. Install your interceptors here. Must be thread-safe. Use class-level reference counting (increment a _install_count under a _install_lock) so nested sandboxes work correctly. Only install if count transitions from 0 to 1.
Check for conflicts before installing. If another library has already patched your target, raise ConflictError.
deactivate()¶
Called when the sandbox exits. Remove interceptors and decrement the count. Only restore originals if the count reaches 0. Must not raise; collect errors for the caller to raise after ContextVar reset.
matches()¶
Called by assert_interaction() to check whether an interaction matches the expected fields. Return True if all expected key-value pairs are satisfied. Must never raise; catch exceptions and return False.
format_interaction()¶
Return a one-line human-readable description of the interaction for error messages and the remaining-timeline display. Example: "[HttpPlugin] POST https://api.example.com/v1".
format_mock_hint()¶
Return copy-pasteable code that would configure a mock for this interaction. Used in UnassertedInteractionsError hints to guide the developer.
format_unmocked_hint()¶
Return copy-pasteable code for mocking a call that fired before reaching the timeline (i.e., the queue was empty or no mock matched). Used in UnmockedInteractionError hints.
format_assert_hint()¶
Return copy-pasteable code that would assert this specific interaction. This hint appears in UnassertedInteractionsError when a test forgets to assert a recorded interaction. The goal is a snippet the developer can copy directly into their test.
If your plugin provides convenience assertion methods (e.g., assert_request, assert_command, assert_log), the hint should show the convenience method, not raw verifier.assert_interaction(). Every built-in bigfoot plugin follows this pattern. Convenience wrappers are easier to read, match the API the developer actually uses, and use the plugin's own parameter names rather than the internal details keys.
For example, HttpPlugin.format_assert_hint() returns:
http.assert_request(
"POST",
"https://api.example.com/items",
headers={'content-type': 'application/json'},
body='{"name": "widget"}',
)
Not the lower-level verifier.assert_interaction(http.request, method="POST", ...) form.
If your plugin does not provide convenience methods, show verifier.assert_interaction() with the correct sentinel and field names so the developer can still copy and paste.
assertable_fields()¶
Return the set of interaction.details keys that callers MUST include in assert_interaction(**expected). Any key returned here that is absent from the caller's **expected causes assert_interaction() to raise MissingAssertionFieldsError before any matching logic runs.
Implement to return only keys that carry meaningful signal. Do not include keys that are redundant with source_id (such as mock_name or method_name when the source already identifies the method). The goal is to prevent silent partial assertions, not to force callers to repeat information already encoded in the source.
For example, MockPlugin returns frozenset({"args", "kwargs"}) because callers should not be able to assert a mock interaction without confirming what it was called with.
Convenience assertion methods¶
Every plugin should provide typed assertion helper methods that wrap verifier.assert_interaction(). These methods are the primary assertion API for test authors: they use domain-specific parameter names, provide IDE autocompletion, and appear in format_assert_hint() error messages.
Pattern: Each convenience method calls verifier.assert_interaction() internally with the correct sentinel and field mapping:
def assert_query(self, query: str) -> None:
"""Assert the next database query interaction.
Convenience wrapper around verifier.assert_interaction().
"""
from bigfoot._context import _get_test_verifier_or_raise
_get_test_verifier_or_raise().assert_interaction(self._sentinel, query=query)
Guidelines:
- Name methods
assert_<action>(e.g.,assert_connect,assert_send,assert_command) - Accept the same fields returned by
assertable_fields(), using domain-specific names - Import
_get_test_verifier_or_raisefrombigfoot._contextto get the current verifier - Update
format_assert_hint()to show the convenience method, notverifier.assert_interaction()
All 14 built-in plugins follow this pattern. The raw verifier.assert_interaction() call still works and is documented as the low-level equivalent, but convenience methods are the recommended API.
get_unused_mocks()¶
Return all mock configuration objects that are required=True and were never consumed. verify_all() iterates plugins and calls this method.
format_unused_mock_hint()¶
Return a hint string for one unused mock. Typically includes the registration traceback and instructions to either remove the mock or mark it required=False.
The record() method (concrete)¶
BasePlugin provides a concrete record() method that appends an Interaction to the verifier's shared timeline:
Call self.record(interaction) from your interceptor after a call fires.
Interaction dataclass fields¶
| Field | Type | Description |
|---|---|---|
source_id |
str |
Unique identifier for this interceptor (e.g., "mock:Name.method", "http:request") |
sequence |
int |
Assigned atomically by Timeline.append(). Set to 0 before recording. |
details |
dict[str, Any] |
Plugin-specific data; queried by matches() and format_*() methods |
plugin |
BasePlugin |
Reference to the plugin that recorded this interaction |
Minimal example: a database plugin¶
import threading
from typing import Any
from bigfoot._base_plugin import BasePlugin
from bigfoot._errors import UnmockedInteractionError
from bigfoot._timeline import Interaction
from bigfoot._verifier import StrictVerifier
class DbMockConfig:
def __init__(self, query: str, result: Any, required: bool = True):
self.query = query
self.result = result
self.required = required
class DatabasePlugin(BasePlugin):
_install_count: int = 0
_install_lock: threading.Lock = threading.Lock()
_original_execute: Any = None
def __init__(self, verifier: StrictVerifier, connection: Any) -> None:
super().__init__(verifier)
self._connection = connection
self._mock_queue: list[DbMockConfig] = []
def mock_query(self, query: str, result: Any, required: bool = True) -> None:
self._mock_queue.append(DbMockConfig(query=query, result=result, required=required))
def activate(self) -> None:
with DatabasePlugin._install_lock:
if DatabasePlugin._install_count == 0:
DatabasePlugin._original_execute = self._connection.__class__.execute
plugin_ref = self
def _interceptor(conn_self: Any, query: str, *args: Any, **kwargs: Any) -> Any:
config = next(
(c for c in plugin_ref._mock_queue if c.query == query), None
)
if config is None:
hint = plugin_ref.format_unmocked_hint("db:execute", (query,), {})
raise UnmockedInteractionError(
source_id="db:execute", args=(query,), kwargs={}, hint=hint
)
plugin_ref._mock_queue.remove(config)
interaction = Interaction(
source_id="db:execute",
sequence=0,
details={"query": query},
plugin=plugin_ref,
)
plugin_ref.record(interaction)
return config.result
self._connection.__class__.execute = _interceptor
DatabasePlugin._install_count += 1
def deactivate(self) -> None:
with DatabasePlugin._install_lock:
DatabasePlugin._install_count = max(0, DatabasePlugin._install_count - 1)
if DatabasePlugin._install_count == 0 and DatabasePlugin._original_execute is not None:
self._connection.__class__.execute = DatabasePlugin._original_execute
DatabasePlugin._original_execute = None
def matches(self, interaction: Interaction, expected: dict[str, Any]) -> bool:
try:
return all(interaction.details.get(k) == v for k, v in expected.items())
except Exception:
return False
def format_interaction(self, interaction: Interaction) -> str:
return f"[DatabasePlugin] execute: {interaction.details.get('query', '?')}"
def format_mock_hint(self, interaction: Interaction) -> str:
query = interaction.details.get("query", "SELECT ...")
return f'db.mock_query("{query}", result=[...])'
def format_unmocked_hint(self, source_id: str, args: tuple, kwargs: dict) -> str:
query = args[0] if args else "SELECT ..."
return (
f"Unexpected DB query: {query}\n\n"
f" To mock this query, add before your sandbox:\n"
f' db.mock_query("{query}", result=[...])'
)
def assert_query(self, query: str) -> None:
"""Assert the next database query interaction.
Convenience wrapper around verifier.assert_interaction().
"""
from bigfoot._context import _get_test_verifier_or_raise
_get_test_verifier_or_raise().assert_interaction(self._sentinel, query=query)
def format_assert_hint(self, interaction: Interaction) -> str:
query = interaction.details.get("query", "?")
return f'db.assert_query(query={query!r})'
def assertable_fields(self, interaction: Interaction) -> frozenset[str]:
return frozenset({"query"})
def get_unused_mocks(self) -> list[DbMockConfig]:
return [c for c in self._mock_queue if c.required]
def format_unused_mock_hint(self, mock_config: object) -> str:
assert isinstance(mock_config, DbMockConfig)
return (
f"db:execute query={mock_config.query!r} was registered but never called.\n"
f" - Remove this mock if it's not needed\n"
f' - Mark it optional: db.mock_query("{mock_config.query}", ..., required=False)'
)
Registering and using the plugin¶
In pytest, use bigfoot.current_verifier() to register the plugin against the autouse verifier:
import bigfoot
def test_db_query():
db = DatabasePlugin(bigfoot.current_verifier(), my_connection)
db.mock_query("SELECT * FROM users", result=[{"id": 1}])
with bigfoot:
rows = my_connection.execute("SELECT * FROM users")
assert rows == [{"id": 1}]
# Convenience wrapper -- recommended:
db.assert_query(query="SELECT * FROM users")
# Equivalent low-level call:
# bigfoot.assert_interaction(db_sentinel, query="SELECT * FROM users")
# verify_all() called automatically at teardown
For manual use outside pytest:
from bigfoot import StrictVerifier
verifier = StrictVerifier()
db = DatabasePlugin(verifier, my_connection)
db.mock_query("SELECT * FROM users", result=[{"id": 1}])
with verifier.sandbox():
rows = my_connection.execute("SELECT * FROM users")
assert rows == [{"id": 1}]
# Convenience wrapper -- recommended:
db.assert_query(query="SELECT * FROM users")
# Equivalent low-level call:
# verifier.assert_interaction(db_sentinel, query="SELECT * FROM users")
verifier.verify_all()
StateMachinePlugin¶
Use StateMachinePlugin when the protocol your plugin models has a defined sequence of states — a connection that must be established before messages can flow, and closed before the object is discarded. Use BasePlugin directly when calls are stateless (HTTP requests, Redis GET/SET, arbitrary method mocks that carry no ordering constraint).
When to choose StateMachinePlugin¶
| Situation | Base class |
|---|---|
| Socket (connect → send/recv → close) | StateMachinePlugin |
| Database (connect → execute → commit → close) | StateMachinePlugin |
| WebSocket (open → send/recv → close) | StateMachinePlugin |
| SMTP (connect → ehlo → login → sendmail → quit) | StateMachinePlugin |
| HTTP request/response cycle | BasePlugin |
| Redis commands (GET, SET, DEL — stateless) | BasePlugin |
| Generic method mock | BasePlugin via MockPlugin |
StateMachinePlugin enforces that method calls happen from the correct state. Calling recv before connect raises InvalidStateError immediately, making bugs visible at the call site rather than as mysterious data corruption later.
Abstract methods¶
StateMachinePlugin requires seven abstract methods from BasePlugin (activate, deactivate, format_interaction, format_mock_hint, format_unmocked_hint, format_assert_hint, and format_unused_mock_hint) plus three of its own:
_initial_state(self) -> str¶
Return the name of the state a fresh connection starts in.
_transitions(self) -> dict[str, dict[str, str]]¶
Return the full transition table as a nested dict:
A method may appear in multiple from-states (for example, a close that is valid from either connected or in_transaction). A method that stays in the same state (like send while connected) uses {current: current} as the from/to pair.
def _transitions(self) -> dict[str, dict[str, str]]:
return {
"connect": {"disconnected": "connected"},
"send": {"connected": "connected"},
"recv": {"connected": "connected"},
"close": {"connected": "closed"},
}
_unmocked_source_id(self) -> str¶
Return the source ID string reported in UnmockedInteractionError when new_session() has not been called before a connection attempt. Conventionally matches the "entry point" interceptor.
Session scripting API¶
Before the sandbox runs, register one session per expected connection:
handle = bigfoot.socket_mock.new_session()
handle.expect("connect", returns=None)
handle.expect("recv", returns=b"pong")
handle.expect("close", returns=None)
new_session() returns a SessionHandle. expect() appends one ScriptStep to the handle's FIFO script and returns the handle, so calls chain naturally:
(bigfoot.socket_mock
.new_session()
.expect("connect", returns=None)
.expect("send", returns=4)
.expect("recv", returns=b"pong")
.expect("close", returns=None))
expect parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
method |
str |
required | Method name, must match a key in _transitions() |
returns |
Any |
required | Value returned when this step executes |
raises |
BaseException \| None |
None |
Exception raised instead of returning |
required |
bool |
True |
When True, teardown reports the step as unused if never consumed |
Sessions are consumed in FIFO order. The first call to the connection entry point (e.g., socket.connect()) pops the first queued SessionHandle and binds it to that connection object. All subsequent method calls on the same connection object consume steps from that handle in order.
Interaction recording and assertions¶
State machine plugins auto-assert every interaction at the time it is recorded. You do not call bigfoot.assert_interaction() for stateful plugins — there is nothing to assert after the sandbox. verify_all() still runs at teardown and will report any required=True steps that were configured but never consumed.
Minimal implementation example¶
import threading
from typing import Any, ClassVar
from bigfoot._state_machine_plugin import StateMachinePlugin
from bigfoot._timeline import Interaction
class FtpPlugin(StateMachinePlugin):
"""Mock plugin for a simple two-state FTP-like protocol."""
_install_count: ClassVar[int] = 0
_install_lock: ClassVar[threading.Lock] = threading.Lock()
_original_connect: ClassVar[Any] = None
# -- StateMachinePlugin abstract methods --------------------------------
def _initial_state(self) -> str:
return "disconnected"
def _transitions(self) -> dict[str, dict[str, str]]:
return {
"connect": {"disconnected": "connected"},
"list": {"connected": "connected"},
"get": {"connected": "connected"},
"put": {"connected": "connected"},
"quit": {"connected": "closed"},
}
def _unmocked_source_id(self) -> str:
return "ftp:connect"
# -- BasePlugin lifecycle -----------------------------------------------
def activate(self) -> None:
with FtpPlugin._install_lock:
if FtpPlugin._install_count == 0:
import ftplib
FtpPlugin._original_connect = ftplib.FTP.connect
# ... install interceptors ...
FtpPlugin._install_count += 1
def deactivate(self) -> None:
with FtpPlugin._install_lock:
FtpPlugin._install_count = max(0, FtpPlugin._install_count - 1)
if FtpPlugin._install_count == 0 and FtpPlugin._original_connect is not None:
import ftplib
ftplib.FTP.connect = FtpPlugin._original_connect
FtpPlugin._original_connect = None
# -- BasePlugin format methods ------------------------------------------
def format_interaction(self, interaction: Interaction) -> str:
method = interaction.details.get("method", "?")
return f"[FtpPlugin] ftp.{method}(...)"
def format_mock_hint(self, interaction: Interaction) -> str:
method = interaction.details.get("method", "?")
return f" bigfoot.ftp_mock.new_session().expect({method!r}, returns=...)"
def format_unmocked_hint(self, source_id: str, args: tuple, kwargs: dict) -> str:
method = source_id.split(":")[-1] if ":" in source_id else source_id
return (
f"ftp.{method}(...) was called but no session was queued.\n"
f"Register a session with:\n"
f" bigfoot.ftp_mock.new_session().expect({method!r}, returns=...)"
)
def format_assert_hint(self, interaction: Interaction) -> str:
method = interaction.details.get("method", "?")
return f" # ftp_mock: session step '{method}' recorded (state-machine, auto-asserted)"
def format_unused_mock_hint(self, mock_config: object) -> str:
method = getattr(mock_config, "method", "?")
tb = getattr(mock_config, "registration_traceback", "")
return (
f"ftp.{method}(...) was mocked (required=True) but never called.\n"
f"Registered at:\n{tb}"
)
Key implementation rules:
- Capture originals at import time (module-level constants) or store them in class variables only after the first
activate(). Never look up originals inside an interceptor function. - Increment
_install_countafter installing patches; decrement before restoring them (or in a pattern where the decrement and restore are atomic under the lock). - Call
_bind_connection(conn_obj)at the connection entry point (e.g., inside the patchedconnect()method) to pop the next queued session. - Call
_lookup_session(conn_obj)inside each subsequent method interceptor to retrieve the bound session. - Call
_release_session(conn_obj)at the close/quit/disconnect point to free the session slot. - Call
_execute_step(handle, method, args, kwargs, source_id)to validate the state transition, pop the next script step, advance the state, record the interaction, and return the configured value.