interposition 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Osoekawa IT Laboratory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: interposition
3
+ Version: 0.1.0
4
+ Summary: Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.
5
+ Author: osoken
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/osoekawaitlab/interposition
8
+ Project-URL: Repository, https://github.com/osoekawaitlab/interposition
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: POSIX
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: pydantic<3.0,>=2.0
22
+ Dynamic: license-file
23
+
24
+ # interposition
25
+
26
+ Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.
27
+
28
+ ## Overview
29
+
30
+ Interposition is a Python library for replaying recorded interactions. Unlike VCRpy or other HTTP-specific tools, **Interposition does not automatically hook into network libraries**.
31
+
32
+ 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.
33
+
34
+ **Key Features:**
35
+
36
+ - **Protocol-agnostic**: Works with any protocol (HTTP, gRPC, SQL, Pub/Sub, etc.)
37
+ - **Type-safe**: Full mypy strict mode support with Pydantic v2
38
+ - **Immutable**: All data structures are frozen Pydantic models
39
+ - **Serializable**: Built-in JSON/YAML serialization for cassette persistence
40
+ - **Memory-efficient**: O(1) lookup with fingerprint indexing
41
+ - **Streaming**: Generator-based response delivery
42
+
43
+ ## Architecture
44
+
45
+ Interposition sits behind your application's data access layer. You provide the "Adapter" that captures live traffic or requests replay from the Broker.
46
+
47
+ ```text
48
+ +-------------+ +------------------+ +---------------+
49
+ | Application | <--> | Your Adapter | <--> | Interposition |
50
+ +-------------+ +------------------+ +---------------+
51
+ | |
52
+ (Traps calls) (Manages)
53
+ |
54
+ [Cassette]
55
+ ```
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install interposition
61
+ ```
62
+
63
+ ## Practical Integration (Pytest Recipe)
64
+
65
+ The most common use case is using Interposition as a test fixture. Here is a production-ready recipe for `pytest`:
66
+
67
+ ```python
68
+ import pytest
69
+ from interposition import Broker, Cassette, InteractionRequest
70
+
71
+ @pytest.fixture
72
+ def cassette_broker():
73
+ # Load cassette from a JSON file (or create one programmatically)
74
+ with open("tests/fixtures/my_cassette.json", "rb") as f:
75
+ cassette = Cassette.model_validate_json(f.read())
76
+ return Broker(cassette)
77
+
78
+ def test_user_service(cassette_broker, monkeypatch):
79
+ # 1. Create your adapter (mocking your actual client)
80
+ def mock_fetch(url):
81
+ request = InteractionRequest(
82
+ protocol="http",
83
+ action="GET",
84
+ target=url,
85
+ headers=(),
86
+ body=b"",
87
+ )
88
+ # Delegate to Interposition
89
+ chunks = list(cassette_broker.replay(request))
90
+ return chunks[0].data
91
+
92
+ # 2. Inject the adapter
93
+ monkeypatch.setattr("my_app.client.fetch", mock_fetch)
94
+
95
+ # 3. Run your test
96
+ from my_app import get_user_name
97
+ assert get_user_name(42) == "Alice"
98
+ ```
99
+
100
+ ## Protocol-Agnostic Examples
101
+
102
+ Interposition shines where HTTP-only tools fail.
103
+
104
+ ### SQL Database Query
105
+
106
+ ```python
107
+ request = InteractionRequest(
108
+ protocol="postgres",
109
+ action="SELECT",
110
+ target="users_table",
111
+ headers=(),
112
+ body=b"SELECT id, name FROM users WHERE id = 42",
113
+ )
114
+ # Replay returns: b'[(42, "Alice")]'
115
+ ```
116
+
117
+ ### MQTT / PubSub Message
118
+
119
+ ```python
120
+ request = InteractionRequest(
121
+ protocol="mqtt",
122
+ action="subscribe",
123
+ target="sensors/temp/room1",
124
+ headers=(("qos", "1"),),
125
+ body=b"",
126
+ )
127
+ # Replay returns stream of messages: b'24.5', b'24.6', ...
128
+ ```
129
+
130
+ ## Usage Guide
131
+
132
+ ### Manual Construction (Quick Start)
133
+
134
+ If you need to build interactions programmatically (e.g., for seeding tests):
135
+
136
+ ```python
137
+ from interposition import (
138
+ Broker,
139
+ Cassette,
140
+ Interaction,
141
+ InteractionRequest,
142
+ ResponseChunk,
143
+ )
144
+
145
+ # 1. Define the Request
146
+ request = InteractionRequest(
147
+ protocol="api",
148
+ action="query",
149
+ target="users/42",
150
+ headers=(),
151
+ body=b"",
152
+ )
153
+
154
+ # 2. Define the Response
155
+ chunks = (
156
+ ResponseChunk(data=b'{"id": 42, "name": "Alice"}', sequence=0),
157
+ )
158
+
159
+ # 3. Create Interaction & Cassette
160
+ interaction = Interaction(
161
+ request=request,
162
+ fingerprint=request.fingerprint(),
163
+ response_chunks=chunks,
164
+ )
165
+ cassette = Cassette(interactions=(interaction,))
166
+
167
+ # 4. Replay
168
+ broker = Broker(cassette=cassette)
169
+ response = list(broker.replay(request))
170
+ ```
171
+
172
+ ### Persistence & Serialization
173
+
174
+ Interposition models are Pydantic v2 models, making serialization trivial.
175
+
176
+ ```python
177
+ # Save to JSON
178
+ with open("cassette.json", "w") as f:
179
+ f.write(cassette.model_dump_json(indent=2))
180
+
181
+ # Load from JSON
182
+ with open("cassette.json") as f:
183
+ cassette = Cassette.model_validate_json(f.read())
184
+
185
+ # Generate JSON Schema
186
+ schema = Cassette.model_json_schema()
187
+ ```
188
+
189
+ ### Streaming Responses
190
+
191
+ For large files or streaming protocols, responses are yielded lazily:
192
+
193
+ ```python
194
+ # The broker returns a generator
195
+ for chunk in broker.replay(request):
196
+ print(f"Received chunk: {len(chunk.data)} bytes")
197
+ ```
198
+
199
+ ### Error Handling
200
+
201
+ If a matching interaction is not found, the broker raises `InteractionNotFoundError`:
202
+
203
+ ```python
204
+ from interposition import InteractionNotFoundError
205
+
206
+ try:
207
+ broker.replay(unknown_request)
208
+ except InteractionNotFoundError as e:
209
+ print(f"Not recorded: {e.request.target}")
210
+ ```
211
+
212
+ ## Development
213
+
214
+ ### Prerequisites
215
+
216
+ - Python 3.10+
217
+ - [uv](https://github.com/astral-sh/uv) (recommended)
218
+
219
+ ### Setup & Testing
220
+
221
+ ```bash
222
+ # Clone and install
223
+ git clone https://github.com/osoekawaitlab/interposition.git
224
+ cd interposition
225
+ uv pip install -e . --group=dev
226
+
227
+ # Run tests
228
+ nox -s tests
229
+ ```
230
+
231
+ ## License
232
+
233
+ MIT
@@ -0,0 +1,210 @@
1
+ # interposition
2
+
3
+ Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.
4
+
5
+ ## Overview
6
+
7
+ Interposition is a Python library for replaying recorded interactions. Unlike VCRpy or other HTTP-specific tools, **Interposition does not automatically hook into network libraries**.
8
+
9
+ 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.
10
+
11
+ **Key Features:**
12
+
13
+ - **Protocol-agnostic**: Works with any protocol (HTTP, gRPC, SQL, Pub/Sub, etc.)
14
+ - **Type-safe**: Full mypy strict mode support with Pydantic v2
15
+ - **Immutable**: All data structures are frozen Pydantic models
16
+ - **Serializable**: Built-in JSON/YAML serialization for cassette persistence
17
+ - **Memory-efficient**: O(1) lookup with fingerprint indexing
18
+ - **Streaming**: Generator-based response delivery
19
+
20
+ ## Architecture
21
+
22
+ Interposition sits behind your application's data access layer. You provide the "Adapter" that captures live traffic or requests replay from the Broker.
23
+
24
+ ```text
25
+ +-------------+ +------------------+ +---------------+
26
+ | Application | <--> | Your Adapter | <--> | Interposition |
27
+ +-------------+ +------------------+ +---------------+
28
+ | |
29
+ (Traps calls) (Manages)
30
+ |
31
+ [Cassette]
32
+ ```
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install interposition
38
+ ```
39
+
40
+ ## Practical Integration (Pytest Recipe)
41
+
42
+ The most common use case is using Interposition as a test fixture. Here is a production-ready recipe for `pytest`:
43
+
44
+ ```python
45
+ import pytest
46
+ from interposition import Broker, Cassette, InteractionRequest
47
+
48
+ @pytest.fixture
49
+ def cassette_broker():
50
+ # Load cassette from a JSON file (or create one programmatically)
51
+ with open("tests/fixtures/my_cassette.json", "rb") as f:
52
+ cassette = Cassette.model_validate_json(f.read())
53
+ return Broker(cassette)
54
+
55
+ def test_user_service(cassette_broker, monkeypatch):
56
+ # 1. Create your adapter (mocking your actual client)
57
+ def mock_fetch(url):
58
+ request = InteractionRequest(
59
+ protocol="http",
60
+ action="GET",
61
+ target=url,
62
+ headers=(),
63
+ body=b"",
64
+ )
65
+ # Delegate to Interposition
66
+ chunks = list(cassette_broker.replay(request))
67
+ return chunks[0].data
68
+
69
+ # 2. Inject the adapter
70
+ monkeypatch.setattr("my_app.client.fetch", mock_fetch)
71
+
72
+ # 3. Run your test
73
+ from my_app import get_user_name
74
+ assert get_user_name(42) == "Alice"
75
+ ```
76
+
77
+ ## Protocol-Agnostic Examples
78
+
79
+ Interposition shines where HTTP-only tools fail.
80
+
81
+ ### SQL Database Query
82
+
83
+ ```python
84
+ request = InteractionRequest(
85
+ protocol="postgres",
86
+ action="SELECT",
87
+ target="users_table",
88
+ headers=(),
89
+ body=b"SELECT id, name FROM users WHERE id = 42",
90
+ )
91
+ # Replay returns: b'[(42, "Alice")]'
92
+ ```
93
+
94
+ ### MQTT / PubSub Message
95
+
96
+ ```python
97
+ request = InteractionRequest(
98
+ protocol="mqtt",
99
+ action="subscribe",
100
+ target="sensors/temp/room1",
101
+ headers=(("qos", "1"),),
102
+ body=b"",
103
+ )
104
+ # Replay returns stream of messages: b'24.5', b'24.6', ...
105
+ ```
106
+
107
+ ## Usage Guide
108
+
109
+ ### Manual Construction (Quick Start)
110
+
111
+ If you need to build interactions programmatically (e.g., for seeding tests):
112
+
113
+ ```python
114
+ from interposition import (
115
+ Broker,
116
+ Cassette,
117
+ Interaction,
118
+ InteractionRequest,
119
+ ResponseChunk,
120
+ )
121
+
122
+ # 1. Define the Request
123
+ request = InteractionRequest(
124
+ protocol="api",
125
+ action="query",
126
+ target="users/42",
127
+ headers=(),
128
+ body=b"",
129
+ )
130
+
131
+ # 2. Define the Response
132
+ chunks = (
133
+ ResponseChunk(data=b'{"id": 42, "name": "Alice"}', sequence=0),
134
+ )
135
+
136
+ # 3. Create Interaction & Cassette
137
+ interaction = Interaction(
138
+ request=request,
139
+ fingerprint=request.fingerprint(),
140
+ response_chunks=chunks,
141
+ )
142
+ cassette = Cassette(interactions=(interaction,))
143
+
144
+ # 4. Replay
145
+ broker = Broker(cassette=cassette)
146
+ response = list(broker.replay(request))
147
+ ```
148
+
149
+ ### Persistence & Serialization
150
+
151
+ Interposition models are Pydantic v2 models, making serialization trivial.
152
+
153
+ ```python
154
+ # Save to JSON
155
+ with open("cassette.json", "w") as f:
156
+ f.write(cassette.model_dump_json(indent=2))
157
+
158
+ # Load from JSON
159
+ with open("cassette.json") as f:
160
+ cassette = Cassette.model_validate_json(f.read())
161
+
162
+ # Generate JSON Schema
163
+ schema = Cassette.model_json_schema()
164
+ ```
165
+
166
+ ### Streaming Responses
167
+
168
+ For large files or streaming protocols, responses are yielded lazily:
169
+
170
+ ```python
171
+ # The broker returns a generator
172
+ for chunk in broker.replay(request):
173
+ print(f"Received chunk: {len(chunk.data)} bytes")
174
+ ```
175
+
176
+ ### Error Handling
177
+
178
+ If a matching interaction is not found, the broker raises `InteractionNotFoundError`:
179
+
180
+ ```python
181
+ from interposition import InteractionNotFoundError
182
+
183
+ try:
184
+ broker.replay(unknown_request)
185
+ except InteractionNotFoundError as e:
186
+ print(f"Not recorded: {e.request.target}")
187
+ ```
188
+
189
+ ## Development
190
+
191
+ ### Prerequisites
192
+
193
+ - Python 3.10+
194
+ - [uv](https://github.com/astral-sh/uv) (recommended)
195
+
196
+ ### Setup & Testing
197
+
198
+ ```bash
199
+ # Clone and install
200
+ git clone https://github.com/osoekawaitlab/interposition.git
201
+ cd interposition
202
+ uv pip install -e . --group=dev
203
+
204
+ # Run tests
205
+ nox -s tests
206
+ ```
207
+
208
+ ## License
209
+
210
+ MIT
@@ -0,0 +1,130 @@
1
+ [project]
2
+ name = "interposition"
3
+ dynamic = ["version"]
4
+ description = "Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "osoken" }
9
+ ]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: POSIX",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ requires-python = ">=3.10"
22
+ dependencies = [
23
+ "pydantic>=2.0,<3.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/osoekawaitlab/interposition"
28
+ Repository = "https://github.com/osoekawaitlab/interposition"
29
+
30
+ [build-system]
31
+ requires = ["setuptools>=70.1"]
32
+ build-backend = "setuptools.build_meta"
33
+
34
+ [tool.setuptools.dynamic]
35
+ version = {attr = "interposition._version.__version__"}
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "getgauge>=0.5.0",
40
+ "mypy>=1.18.2",
41
+ "nox>=2025.11.12",
42
+ "pytest>=9.0.1",
43
+ "pytest-cov>=7.0.0",
44
+ "pytest-mock>=3.15.1",
45
+ "pytest-randomly>=4.0.1",
46
+ "ruff>=0.14.5",
47
+ ]
48
+ docs = [
49
+ "mkdocs>=1.6.1",
50
+ "mkdocs-awesome-nav>=3.2.0",
51
+ "mkdocs-material>=9.7.0",
52
+ "mkdocstrings>=0.30.1",
53
+ "mkdocstrings-python>=1.19.0",
54
+ ]
55
+
56
+ [tool.ruff]
57
+ indent-width = 4
58
+ target-version = "py310"
59
+ line-length = 88
60
+
61
+ [tool.ruff.lint]
62
+ select = ["ALL"]
63
+ ignore = [
64
+ "COM812", # trailing-comma-missing (conflicts with formatter)
65
+ "ISC001", # single-line-implicit-string-concatenation (conflicts with formatter)
66
+ "D203", # one-blank-line-before-class (conflicts with D211)
67
+ "D213", # multi-line-summary-second-line (conflicts with D212)
68
+ ]
69
+
70
+ [tool.ruff.lint.per-file-ignores]
71
+ "tests/**/*.py" = [
72
+ "S101", # assert-used (pytest uses asserts)
73
+ ]
74
+ "e2e/**/*.py" = [
75
+ "S101", # assert-used (E2E tests use asserts)
76
+ "S607", # start-process-with-partial-path
77
+ ]
78
+
79
+ [tool.ruff.lint.pydocstyle]
80
+ convention = "google"
81
+
82
+ [tool.ruff.format]
83
+ quote-style = "double"
84
+ indent-style = "space"
85
+ skip-magic-trailing-comma = false
86
+ line-ending = "auto"
87
+
88
+ [tool.mypy]
89
+ python_version = "3.10"
90
+ strict = true
91
+ check_untyped_defs = true
92
+ disallow_any_explicit = true
93
+ disallow_any_generics = true
94
+ disallow_incomplete_defs = true
95
+ disallow_untyped_decorators = true
96
+ disallow_untyped_defs = true
97
+ no_implicit_optional = true
98
+ show_error_codes = true
99
+ strict_equality = true
100
+ warn_no_return = true
101
+ warn_redundant_casts = true
102
+ warn_return_any = true
103
+ warn_unreachable = true
104
+ warn_unused_configs = true
105
+ warn_unused_ignores = true
106
+ mypy_path = "e2e/stubs"
107
+ plugins = ["pydantic.mypy"]
108
+
109
+ [[tool.mypy.overrides]]
110
+ module = "pydantic"
111
+ disallow_any_explicit = false
112
+
113
+ [tool.pydantic-mypy]
114
+ init_forbid_extra = true
115
+ init_typed = true
116
+ warn_required_dynamic_aliases = true
117
+
118
+ [tool.pytest.ini_options]
119
+ testpaths = ["tests", "src"]
120
+ python_files = ["test_*.py", "*_test.py"]
121
+ python_functions = ["test_*"]
122
+ addopts = [
123
+ "--strict-markers",
124
+ "--strict-config",
125
+ "--doctest-modules",
126
+ ]
127
+ markers = [
128
+ "unit: Unit tests (pure logic, no external dependencies)",
129
+ "e2e: End-to-end tests with mocked SDKs",
130
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,28 @@
1
+ """Protocol-agnostic interaction interposition with lifecycle hooks.
2
+
3
+ Provides record, replay, and control capabilities.
4
+ """
5
+
6
+ from interposition._version import __version__
7
+ from interposition.errors import InteractionNotFoundError
8
+ from interposition.models import (
9
+ Cassette,
10
+ Interaction,
11
+ InteractionRequest,
12
+ InteractionValidationError,
13
+ RequestFingerprint,
14
+ ResponseChunk,
15
+ )
16
+ from interposition.services import Broker
17
+
18
+ __all__ = [
19
+ "Broker",
20
+ "Cassette",
21
+ "Interaction",
22
+ "InteractionNotFoundError",
23
+ "InteractionRequest",
24
+ "InteractionValidationError",
25
+ "RequestFingerprint",
26
+ "ResponseChunk",
27
+ "__version__",
28
+ ]
@@ -0,0 +1,3 @@
1
+ """Version information for interposition."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,24 @@
1
+ """Exceptions for interposition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from interposition.models import InteractionRequest
9
+
10
+
11
+ class InteractionNotFoundError(Exception):
12
+ """Raised when no matching interaction is found in cassette."""
13
+
14
+ def __init__(self, request: InteractionRequest) -> None:
15
+ """Initialize with request that failed to match.
16
+
17
+ Args:
18
+ request: The unmatched request
19
+ """
20
+ super().__init__(
21
+ f"No matching interaction for {request.protocol}:"
22
+ f"{request.action}:{request.target}"
23
+ )
24
+ self.request: InteractionRequest = request
@@ -0,0 +1,225 @@
1
+ """Data models for interposition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ ConfigDict,
11
+ PrivateAttr,
12
+ field_validator,
13
+ model_validator,
14
+ )
15
+ from typing_extensions import Self
16
+
17
+ SHA256_HEX_LENGTH = 64
18
+
19
+ # JSON serialization settings for canonical fingerprint generation
20
+ _CANONICAL_JSON_SEPARATORS = (",", ":")
21
+ _CANONICAL_JSON_SORT_KEYS = True
22
+
23
+
24
+ class InteractionValidationError(ValueError):
25
+ """Raised when interaction validation fails."""
26
+
27
+
28
+ class ResponseChunk(BaseModel):
29
+ """Discrete piece of response data.
30
+
31
+ Attributes:
32
+ data: Chunk payload as bytes
33
+ sequence: Zero-based chunk position in response stream
34
+ metadata: Optional chunk metadata as (key, value) string pairs.
35
+ Examples: timing info, encoding, content-type for this chunk.
36
+ Default is empty tuple.
37
+ """
38
+
39
+ model_config = ConfigDict(frozen=True)
40
+
41
+ data: bytes
42
+ sequence: int
43
+ metadata: tuple[tuple[str, str], ...] = ()
44
+
45
+
46
+ class InteractionRequest(BaseModel):
47
+ """Structured representation of a protocol-agnostic request.
48
+
49
+ Attributes:
50
+ protocol: Protocol identifier (e.g., "grpc", "graphql", "mqtt")
51
+ action: Action/method name (e.g., "ListUsers", "query", "publish")
52
+ target: Target resource (e.g., "users.UserService", "topic/sensors")
53
+ headers: Request headers as immutable sequence of key-value pairs
54
+ body: Request body content as bytes
55
+ """
56
+
57
+ model_config = ConfigDict(frozen=True)
58
+
59
+ protocol: str
60
+ action: str
61
+ target: str
62
+ headers: tuple[tuple[str, str], ...]
63
+ body: bytes
64
+
65
+ def fingerprint(self) -> RequestFingerprint:
66
+ """Generate stable fingerprint for efficient matching.
67
+
68
+ Returns:
69
+ RequestFingerprint derived from all request fields.
70
+ """
71
+ return RequestFingerprint.from_request(self)
72
+
73
+
74
+ class RequestFingerprint(BaseModel):
75
+ """Stable unique identifier for request matching.
76
+
77
+ Attributes:
78
+ value: SHA-256 hash of canonicalized request fields
79
+ """
80
+
81
+ model_config = ConfigDict(frozen=True)
82
+
83
+ value: str
84
+
85
+ @field_validator("value")
86
+ @classmethod
87
+ def validate_sha256_hex(cls, v: str) -> str:
88
+ """Validate that value is a valid SHA-256 hex string.
89
+
90
+ Args:
91
+ v: The fingerprint value to validate
92
+
93
+ Returns:
94
+ The validated value
95
+
96
+ Raises:
97
+ ValueError: If value is not exactly 64 hex characters
98
+ """
99
+ if len(v) != SHA256_HEX_LENGTH:
100
+ msg = f"SHA-256 hex must be exactly {SHA256_HEX_LENGTH} characters"
101
+ raise ValueError(msg)
102
+ if not all(c in "0123456789abcdef" for c in v):
103
+ msg = "Invalid hex characters in fingerprint"
104
+ raise ValueError(msg)
105
+ return v
106
+
107
+ @classmethod
108
+ def from_request(cls, request: InteractionRequest) -> Self:
109
+ """Create fingerprint from InteractionRequest.
110
+
111
+ Args:
112
+ request: The request to fingerprint
113
+
114
+ Returns:
115
+ RequestFingerprint with SHA-256 hash value
116
+ """
117
+ # Canonical order: protocol, action, target, headers, body
118
+ # Preserve header ordering to avoid normalization.
119
+ canonical_data = [
120
+ request.protocol,
121
+ request.action,
122
+ request.target,
123
+ request.headers,
124
+ request.body.hex(),
125
+ ]
126
+ canonical = json.dumps(
127
+ canonical_data,
128
+ separators=_CANONICAL_JSON_SEPARATORS,
129
+ sort_keys=_CANONICAL_JSON_SORT_KEYS,
130
+ )
131
+ hash_value = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
132
+ return cls(value=hash_value)
133
+
134
+
135
+ class Interaction(BaseModel):
136
+ """Complete request-response pair for replay.
137
+
138
+ Attributes:
139
+ request: The original InteractionRequest
140
+ fingerprint: Precomputed request fingerprint for matching
141
+ response_chunks: Ordered sequence of response chunks
142
+ metadata: Optional interaction metadata as (key, value) pairs.
143
+ Examples: recording timestamp, session ID, test scenario name.
144
+ Useful for debugging and tracing recorded interactions.
145
+ Default is empty tuple.
146
+ """
147
+
148
+ model_config = ConfigDict(frozen=True)
149
+
150
+ request: InteractionRequest
151
+ fingerprint: RequestFingerprint
152
+ response_chunks: tuple[ResponseChunk, ...]
153
+ metadata: tuple[tuple[str, str], ...] = ()
154
+
155
+ @model_validator(mode="after")
156
+ def validate_interaction(self) -> Self:
157
+ """Validate interaction integrity.
158
+
159
+ Raises:
160
+ InteractionValidationError: If fingerprint doesn't match request
161
+ or chunks aren't sequential
162
+ """
163
+ # Verify fingerprint matches request
164
+ expected_fingerprint = self.request.fingerprint()
165
+ if self.fingerprint != expected_fingerprint:
166
+ msg = (
167
+ f"Fingerprint does not match request: "
168
+ f"expected {expected_fingerprint.value}, got {self.fingerprint.value}"
169
+ )
170
+ raise InteractionValidationError(msg)
171
+
172
+ # Verify response chunks are sequentially ordered
173
+ if not self.response_chunks:
174
+ msg = "Response chunks cannot be empty"
175
+ raise InteractionValidationError(msg)
176
+
177
+ if self.response_chunks[0].sequence != 0:
178
+ msg = "Response chunks must start at sequence 0"
179
+ raise InteractionValidationError(msg)
180
+
181
+ for i, chunk in enumerate(self.response_chunks):
182
+ if chunk.sequence != i:
183
+ msg = "Response chunks must be sequential with no gaps"
184
+ raise InteractionValidationError(msg)
185
+
186
+ return self
187
+
188
+
189
+ class Cassette(BaseModel):
190
+ """In-memory collection of recorded interactions.
191
+
192
+ Attributes:
193
+ interactions: Ordered sequence of interactions
194
+ """
195
+
196
+ model_config = ConfigDict(frozen=True)
197
+
198
+ interactions: tuple[Interaction, ...]
199
+ _index: dict[RequestFingerprint, int] = PrivateAttr(default_factory=dict)
200
+
201
+ @model_validator(mode="after")
202
+ def build_index(self) -> Self:
203
+ """Build fingerprint index for efficient lookup."""
204
+ index: dict[RequestFingerprint, int] = {}
205
+ for i, interaction in enumerate(self.interactions):
206
+ # Only store first occurrence of each fingerprint
207
+ if interaction.fingerprint not in index:
208
+ index[interaction.fingerprint] = i
209
+ # Use object.__setattr__ to modify frozen model
210
+ object.__setattr__(self, "_index", index)
211
+ return self
212
+
213
+ def find_interaction(self, fingerprint: RequestFingerprint) -> Interaction | None:
214
+ """Find first interaction matching fingerprint.
215
+
216
+ Args:
217
+ fingerprint: Request fingerprint to search for
218
+
219
+ Returns:
220
+ Matching Interaction or None if not found
221
+ """
222
+ position = self._index.get(fingerprint)
223
+ if position is None:
224
+ return None
225
+ return self.interactions[position]
File without changes
@@ -0,0 +1,53 @@
1
+ """Domain services for interposition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from interposition.errors import InteractionNotFoundError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterator
11
+
12
+ from interposition.models import Cassette, InteractionRequest, ResponseChunk
13
+
14
+
15
+ class Broker:
16
+ """Manages interaction replay from cassettes.
17
+
18
+ Attributes:
19
+ cassette: The cassette containing recorded interactions
20
+ """
21
+
22
+ def __init__(self, cassette: Cassette) -> None:
23
+ """Initialize broker with a cassette.
24
+
25
+ Args:
26
+ cassette: The cassette containing recorded interactions
27
+ """
28
+ self._cassette = cassette
29
+
30
+ @property
31
+ def cassette(self) -> Cassette:
32
+ """Get the cassette."""
33
+ return self._cassette
34
+
35
+ def replay(self, request: InteractionRequest) -> Iterator[ResponseChunk]:
36
+ """Replay recorded response for matching request.
37
+
38
+ Args:
39
+ request: The request to match and replay
40
+
41
+ Yields:
42
+ ResponseChunks in original recorded order
43
+
44
+ Raises:
45
+ InteractionNotFoundError: When no matching interaction exists
46
+ """
47
+ fingerprint = request.fingerprint()
48
+ interaction = self.cassette.find_interaction(fingerprint)
49
+
50
+ if interaction is None:
51
+ raise InteractionNotFoundError(request)
52
+
53
+ yield from interaction.response_chunks
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: interposition
3
+ Version: 0.1.0
4
+ Summary: Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.
5
+ Author: osoken
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/osoekawaitlab/interposition
8
+ Project-URL: Repository, https://github.com/osoekawaitlab/interposition
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: POSIX
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: pydantic<3.0,>=2.0
22
+ Dynamic: license-file
23
+
24
+ # interposition
25
+
26
+ Protocol-agnostic interaction interposition with lifecycle hooks for record, replay, and control.
27
+
28
+ ## Overview
29
+
30
+ Interposition is a Python library for replaying recorded interactions. Unlike VCRpy or other HTTP-specific tools, **Interposition does not automatically hook into network libraries**.
31
+
32
+ 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.
33
+
34
+ **Key Features:**
35
+
36
+ - **Protocol-agnostic**: Works with any protocol (HTTP, gRPC, SQL, Pub/Sub, etc.)
37
+ - **Type-safe**: Full mypy strict mode support with Pydantic v2
38
+ - **Immutable**: All data structures are frozen Pydantic models
39
+ - **Serializable**: Built-in JSON/YAML serialization for cassette persistence
40
+ - **Memory-efficient**: O(1) lookup with fingerprint indexing
41
+ - **Streaming**: Generator-based response delivery
42
+
43
+ ## Architecture
44
+
45
+ Interposition sits behind your application's data access layer. You provide the "Adapter" that captures live traffic or requests replay from the Broker.
46
+
47
+ ```text
48
+ +-------------+ +------------------+ +---------------+
49
+ | Application | <--> | Your Adapter | <--> | Interposition |
50
+ +-------------+ +------------------+ +---------------+
51
+ | |
52
+ (Traps calls) (Manages)
53
+ |
54
+ [Cassette]
55
+ ```
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install interposition
61
+ ```
62
+
63
+ ## Practical Integration (Pytest Recipe)
64
+
65
+ The most common use case is using Interposition as a test fixture. Here is a production-ready recipe for `pytest`:
66
+
67
+ ```python
68
+ import pytest
69
+ from interposition import Broker, Cassette, InteractionRequest
70
+
71
+ @pytest.fixture
72
+ def cassette_broker():
73
+ # Load cassette from a JSON file (or create one programmatically)
74
+ with open("tests/fixtures/my_cassette.json", "rb") as f:
75
+ cassette = Cassette.model_validate_json(f.read())
76
+ return Broker(cassette)
77
+
78
+ def test_user_service(cassette_broker, monkeypatch):
79
+ # 1. Create your adapter (mocking your actual client)
80
+ def mock_fetch(url):
81
+ request = InteractionRequest(
82
+ protocol="http",
83
+ action="GET",
84
+ target=url,
85
+ headers=(),
86
+ body=b"",
87
+ )
88
+ # Delegate to Interposition
89
+ chunks = list(cassette_broker.replay(request))
90
+ return chunks[0].data
91
+
92
+ # 2. Inject the adapter
93
+ monkeypatch.setattr("my_app.client.fetch", mock_fetch)
94
+
95
+ # 3. Run your test
96
+ from my_app import get_user_name
97
+ assert get_user_name(42) == "Alice"
98
+ ```
99
+
100
+ ## Protocol-Agnostic Examples
101
+
102
+ Interposition shines where HTTP-only tools fail.
103
+
104
+ ### SQL Database Query
105
+
106
+ ```python
107
+ request = InteractionRequest(
108
+ protocol="postgres",
109
+ action="SELECT",
110
+ target="users_table",
111
+ headers=(),
112
+ body=b"SELECT id, name FROM users WHERE id = 42",
113
+ )
114
+ # Replay returns: b'[(42, "Alice")]'
115
+ ```
116
+
117
+ ### MQTT / PubSub Message
118
+
119
+ ```python
120
+ request = InteractionRequest(
121
+ protocol="mqtt",
122
+ action="subscribe",
123
+ target="sensors/temp/room1",
124
+ headers=(("qos", "1"),),
125
+ body=b"",
126
+ )
127
+ # Replay returns stream of messages: b'24.5', b'24.6', ...
128
+ ```
129
+
130
+ ## Usage Guide
131
+
132
+ ### Manual Construction (Quick Start)
133
+
134
+ If you need to build interactions programmatically (e.g., for seeding tests):
135
+
136
+ ```python
137
+ from interposition import (
138
+ Broker,
139
+ Cassette,
140
+ Interaction,
141
+ InteractionRequest,
142
+ ResponseChunk,
143
+ )
144
+
145
+ # 1. Define the Request
146
+ request = InteractionRequest(
147
+ protocol="api",
148
+ action="query",
149
+ target="users/42",
150
+ headers=(),
151
+ body=b"",
152
+ )
153
+
154
+ # 2. Define the Response
155
+ chunks = (
156
+ ResponseChunk(data=b'{"id": 42, "name": "Alice"}', sequence=0),
157
+ )
158
+
159
+ # 3. Create Interaction & Cassette
160
+ interaction = Interaction(
161
+ request=request,
162
+ fingerprint=request.fingerprint(),
163
+ response_chunks=chunks,
164
+ )
165
+ cassette = Cassette(interactions=(interaction,))
166
+
167
+ # 4. Replay
168
+ broker = Broker(cassette=cassette)
169
+ response = list(broker.replay(request))
170
+ ```
171
+
172
+ ### Persistence & Serialization
173
+
174
+ Interposition models are Pydantic v2 models, making serialization trivial.
175
+
176
+ ```python
177
+ # Save to JSON
178
+ with open("cassette.json", "w") as f:
179
+ f.write(cassette.model_dump_json(indent=2))
180
+
181
+ # Load from JSON
182
+ with open("cassette.json") as f:
183
+ cassette = Cassette.model_validate_json(f.read())
184
+
185
+ # Generate JSON Schema
186
+ schema = Cassette.model_json_schema()
187
+ ```
188
+
189
+ ### Streaming Responses
190
+
191
+ For large files or streaming protocols, responses are yielded lazily:
192
+
193
+ ```python
194
+ # The broker returns a generator
195
+ for chunk in broker.replay(request):
196
+ print(f"Received chunk: {len(chunk.data)} bytes")
197
+ ```
198
+
199
+ ### Error Handling
200
+
201
+ If a matching interaction is not found, the broker raises `InteractionNotFoundError`:
202
+
203
+ ```python
204
+ from interposition import InteractionNotFoundError
205
+
206
+ try:
207
+ broker.replay(unknown_request)
208
+ except InteractionNotFoundError as e:
209
+ print(f"Not recorded: {e.request.target}")
210
+ ```
211
+
212
+ ## Development
213
+
214
+ ### Prerequisites
215
+
216
+ - Python 3.10+
217
+ - [uv](https://github.com/astral-sh/uv) (recommended)
218
+
219
+ ### Setup & Testing
220
+
221
+ ```bash
222
+ # Clone and install
223
+ git clone https://github.com/osoekawaitlab/interposition.git
224
+ cd interposition
225
+ uv pip install -e . --group=dev
226
+
227
+ # Run tests
228
+ nox -s tests
229
+ ```
230
+
231
+ ## License
232
+
233
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/interposition/__init__.py
5
+ src/interposition/_version.py
6
+ src/interposition/errors.py
7
+ src/interposition/models.py
8
+ src/interposition/py.typed
9
+ src/interposition/services.py
10
+ src/interposition.egg-info/PKG-INFO
11
+ src/interposition.egg-info/SOURCES.txt
12
+ src/interposition.egg-info/dependency_links.txt
13
+ src/interposition.egg-info/requires.txt
14
+ src/interposition.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ pydantic<3.0,>=2.0
@@ -0,0 +1 @@
1
+ interposition