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 FileNotFoundError 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))

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}")

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.3.0.tar.gz (14.8 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.3.0-py3-none-any.whl (12.4 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for interposition-0.3.0.tar.gz
Algorithm Hash digest
SHA256 69d6b909eedb21b31c271683d8ea7043899f031557b8a239ce0cb383c6b02937
MD5 3ab7c9642c23b7a8c67f999aaf2584d8
BLAKE2b-256 688fbab6eea9095e2fbe5a2782d505adeb9ce89edb189b85ca14b7b10cab1d49

See more details on using hashes here.

Provenance

The following attestation bundles were made for interposition-0.3.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.3.0-py3-none-any.whl.

File metadata

  • Download URL: interposition-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 12.4 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.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5e7ca378e02458d478dab056bc99a7a8f2d6bf4fd4999ad594f0caae84299917
MD5 3a184a2205ec25119d124acb5bd33925
BLAKE2b-256 af5b872eb8c8c227bf941b10e9c6a2b144dad9f3e3365030eb5107c7a33a9057

See more details on using hashes here.

Provenance

The following attestation bundles were made for interposition-0.3.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