Skip to main content

Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.

Project description

interposition

Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.

Overview

Interposition is a Python library for replaying recorded interactions. Unlike VCRpy or other HTTP-specific tools, Interposition does not automatically hook into network libraries.

Instead, it provides a pure logic engine for storage, matching, and replay. You write the adapter for your specific target (HTTP client, database driver, IoT message handler), and Interposition handles the rest.

Key Features:

  • Protocol-agnostic: Works with any protocol (HTTP, gRPC, SQL, Pub/Sub, etc.)
  • Type-safe: Full mypy strict mode support with Pydantic v2
  • Immutable: All data structures are frozen Pydantic models
  • Serializable: Built-in JSON/YAML serialization for cassette persistence
  • Memory-efficient: O(1) lookup with fingerprint indexing
  • Streaming: Generator-based response delivery
  • Multi-mode: Supports replay, record, and auto modes

Architecture

Interposition sits behind your application's data access layer. You provide the "Adapter" that captures live traffic or requests replay from the Broker.

+-------------+      +------------------+      +---------------+
| Application | <--> | Your Adapter     | <--> | Interposition |
+-------------+      +------------------+      +---------------+
                            |                          |
                       (Traps calls)              (Manages)
                                                       |
                                                  [Cassette]

Installation

pip install interposition

Practical Integration (Pytest Recipe)

The most common use case is using Interposition as a test fixture. Here is a production-ready recipe for pytest:

import pytest
from interposition import Broker, Cassette, InteractionRequest

@pytest.fixture
def cassette_broker():
    # Load cassette from a JSON file (or create one programmatically)
    with open("tests/fixtures/my_cassette.json", "rb") as f:
        cassette = Cassette.model_validate_json(f.read())
    return Broker(cassette)

def test_user_service(cassette_broker, monkeypatch):
    # 1. Create your adapter (mocking your actual client)
    def mock_fetch(url):
        request = InteractionRequest(
            protocol="http",
            action="GET",
            target=url,
            headers=(),
            body=b"",
        )
        # Delegate to Interposition
        chunks = list(cassette_broker.replay(request))
        return chunks[0].data

    # 2. Inject the adapter
    monkeypatch.setattr("my_app.client.fetch", mock_fetch)

    # 3. Run your test
    from my_app import get_user_name
    assert get_user_name(42) == "Alice"

Protocol-Agnostic Examples

Interposition shines where HTTP-only tools fail.

SQL Database Query

request = InteractionRequest(
    protocol="postgres",
    action="SELECT",
    target="users_table",
    headers=(),
    body=b"SELECT id, name FROM users WHERE id = 42",
)
# Replay returns: b'[(42, "Alice")]'

MQTT / PubSub Message

request = InteractionRequest(
    protocol="mqtt",
    action="subscribe",
    target="sensors/temp/room1",
    headers=(("qos", "1"),),
    body=b"",
)
# Replay returns stream of messages: b'24.5', b'24.6', ...

Usage Guide

Manual Construction (Quick Start)

If you need to build interactions programmatically (e.g., for seeding tests):

from interposition import (
    Broker,
    Cassette,
    Interaction,
    InteractionRequest,
    ResponseChunk,
)

# 1. Define the Request
request = InteractionRequest(
    protocol="api",
    action="query",
    target="users/42",
    headers=(),
    body=b"",
)

# 2. Define the Response
chunks = (
    ResponseChunk(data=b'{"id": 42, "name": "Alice"}', sequence=0),
)

# 3. Create Interaction & Cassette
interaction = Interaction(
    request=request,
    fingerprint=request.fingerprint(),
    response_chunks=chunks,
)
cassette = Cassette(interactions=(interaction,))

# 4. Replay
broker = Broker(cassette=cassette)
response = list(broker.replay(request))

Persistence & Serialization

Interposition models are Pydantic v2 models, making serialization trivial.

# Save to JSON
with open("cassette.json", "w") as f:
    f.write(cassette.model_dump_json(indent=2))

# Load from JSON
with open("cassette.json") as f:
    cassette = Cassette.model_validate_json(f.read())

# Generate JSON Schema
schema = Cassette.model_json_schema()

Streaming Responses

For large files or streaming protocols, responses are yielded lazily:

# The broker returns a generator
for chunk in broker.replay(request):
    print(f"Received chunk: {len(chunk.data)} bytes")

Broker Modes

The Broker supports three modes via the mode parameter:

Mode Behavior
replay Default. Returns recorded responses only. Raises InteractionNotFoundError on cache miss.
record Always forwards to live responder and records. Ignores existing cassette entries.
auto Returns recorded response if available; otherwise forwards to live and records.

The BrokerMode type alias is available for type hints:

from interposition import BrokerMode

mode: BrokerMode = "auto"

Live Responder

For record and auto modes, you must provide a live_responder callable that forwards requests to your actual backend:

from interposition import (
    Broker,
    Cassette,
    InteractionRequest,
    ResponseChunk,
)
from collections.abc import Iterable

def my_live_responder(request: InteractionRequest) -> Iterable[ResponseChunk]:
    """Forward request to actual backend and yield response chunks."""
    # Your actual implementation here
    response = your_http_client.request(
        method=request.action,
        url=request.target,
        headers=dict(request.headers),
        data=request.body,
    )
    yield ResponseChunk(data=response.content, sequence=0)

The LiveResponder type alias is available:

from interposition.services import LiveResponder

Record Mode

Use record mode to capture new interactions:

# Start with empty cassette
cassette = Cassette(interactions=())

broker = Broker(
    cassette=cassette,
    mode="record",
    live_responder=my_live_responder,
)

# All requests are forwarded and recorded
response = list(broker.replay(request))

# Save the updated cassette
with open("cassette.json", "w") as f:
    f.write(broker.cassette.model_dump_json(indent=2))

Auto Mode

Use auto mode for hybrid workflows (replay if available, record if not):

# Load existing cassette (may be empty or partial)
with open("cassette.json") as f:
    cassette = Cassette.model_validate_json(f.read())

broker = Broker(
    cassette=cassette,
    mode="auto",
    live_responder=my_live_responder,
)

# Returns recorded response if exists, otherwise forwards and records
response = list(broker.replay(request))

Cassette Store

For automatic cassette persistence during recording, use a CassetteStore. The CassetteStore protocol defines a simple interface for loading and saving cassettes:

from interposition import CassetteStore

class MyCassetteStore:
    """Custom store implementation."""

    def load(self) -> Cassette:
        """Load cassette from storage."""
        ...

    def save(self, cassette: Cassette) -> None:
        """Save cassette to storage."""
        ...

When a cassette_store is provided to the Broker, it automatically saves the cassette after each recorded interaction.

JsonFileCassetteStore

A built-in file-based cassette store using JSON format:

from pathlib import Path
from interposition import Broker, Cassette, JsonFileCassetteStore

# Create store pointing to a JSON file
store = JsonFileCassetteStore(Path("cassettes/my_test.json"))

# Load existing cassette (raises CassetteLoadError if not exists)
cassette = store.load()

# Or start with empty cassette
cassette = Cassette(interactions=())

# Create broker with automatic persistence
broker = Broker(
    cassette=cassette,
    mode="record",
    live_responder=my_live_responder,
    cassette_store=store,  # Auto-saves after each recording
)

# After replay, cassette is automatically saved to file
response = list(broker.replay(request))

By default, load() raises CassetteLoadError if the file doesn't exist. Use create_if_missing=True to return an empty cassette instead — useful for record/auto workflows where the file is created on first save:

store = JsonFileCassetteStore(
    Path("cassettes/my_test.json"),
    create_if_missing=True,
)
cassette = store.load()  # Returns empty Cassette if file doesn't exist

The JsonFileCassetteStore creates parent directories automatically when saving. If saving fails, the error is propagated and response streaming stops (fail-fast).

Error Handling

All interposition exceptions inherit from InterpositionError, allowing you to catch all domain errors with a single handler:

from interposition import InterpositionError

try:
    broker.replay(request)
except InterpositionError as e:
    print(f"Interposition error: {e}")

InteractionNotFoundError: Raised when no matching interaction exists (in replay mode) or when auto mode has a cache miss without a configured live_responder:

from interposition import InteractionNotFoundError

try:
    broker.replay(unknown_request)
except InteractionNotFoundError as e:
    print(f"Not recorded: {e.request.target}")

LiveResponderRequiredError: Raised when record mode is used without a live_responder:

from interposition import LiveResponderRequiredError

broker = Broker(cassette=cassette, mode="record")  # No live_responder!

try:
    broker.replay(request)
except LiveResponderRequiredError as e:
    print(f"live_responder required for {e.mode} mode")

InteractionValidationError: Raised when an Interaction fails validation (e.g., fingerprint mismatch or invalid response chunk sequence):

from interposition import Interaction, InteractionValidationError

try:
    # This will fail: fingerprint doesn't match request
    interaction = Interaction(
        request=request,
        fingerprint=wrong_fingerprint,  # Mismatch!
        response_chunks=chunks,
    )
except InteractionValidationError as e:
    print(f"Validation failed: {e}")

CassetteLoadError: Raised when JsonFileCassetteStore.load() fails (file not found, permission denied, corrupted JSON, etc.):

from pathlib import Path
from interposition import CassetteLoadError, JsonFileCassetteStore

store = JsonFileCassetteStore(Path("cassettes/missing.json"))

try:
    cassette = store.load()
except CassetteLoadError as e:
    print(f"Failed to load from {e.path}: {e.__cause__}")

CassetteSaveError: Raised when JsonFileCassetteStore.save() fails due to I/O errors (permission denied, disk full, etc.):

from pathlib import Path
from interposition import CassetteSaveError, JsonFileCassetteStore

store = JsonFileCassetteStore(Path("/readonly/cassette.json"))

try:
    store.save(cassette)
except CassetteSaveError as e:
    print(f"Failed to save to {e.path}: {e.__cause__}")

Version

Access the package version programmatically:

from interposition import __version__

if __version__ < "0.2.0":
    print("Auto mode is not supported")
else:
    print("Auto mode is supported")

Development

Prerequisites

  • Python 3.10+
  • uv (recommended)

Setup & Testing

# Clone and install
git clone https://github.com/osoekawaitlab/interposition.git
cd interposition
uv pip install -e . --group=dev

# Run tests
nox -s tests

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

interposition-0.4.0.tar.gz (15.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

interposition-0.4.0-py3-none-any.whl (12.8 kB view details)

Uploaded Python 3

File details

Details for the file interposition-0.4.0.tar.gz.

File metadata

  • Download URL: interposition-0.4.0.tar.gz
  • Upload date:
  • Size: 15.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for interposition-0.4.0.tar.gz
Algorithm Hash digest
SHA256 eafbcc6d7d4b7aaa25745c649036f04ecdf66b0723b922407809d96945ffbe28
MD5 b7c83974fc3f7dba767e569d7fef14b2
BLAKE2b-256 f3aabdbcd61e0b3502b6e67295f23fa33fb36bb97f724d2e003cced4f757bb54

See more details on using hashes here.

Provenance

The following attestation bundles were made for interposition-0.4.0.tar.gz:

Publisher: release.yml on osoekawaitlab/interposition

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file interposition-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: interposition-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 12.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for interposition-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 921a2c25fd84384a6630a842805987fa875b91413797d13aa2f057e952ae7d78
MD5 f6d1663c6c315ae69a62adcc42179a07
BLAKE2b-256 d1e9cf34ae0a233a5df7ab12a6da3532e92b45ca401ede5d4eb46f4c63b62499

See more details on using hashes here.

Provenance

The following attestation bundles were made for interposition-0.4.0-py3-none-any.whl:

Publisher: release.yml on osoekawaitlab/interposition

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page