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.
- interposition-0.1.0/LICENSE +21 -0
- interposition-0.1.0/PKG-INFO +233 -0
- interposition-0.1.0/README.md +210 -0
- interposition-0.1.0/pyproject.toml +130 -0
- interposition-0.1.0/setup.cfg +4 -0
- interposition-0.1.0/src/interposition/__init__.py +28 -0
- interposition-0.1.0/src/interposition/_version.py +3 -0
- interposition-0.1.0/src/interposition/errors.py +24 -0
- interposition-0.1.0/src/interposition/models.py +225 -0
- interposition-0.1.0/src/interposition/py.typed +0 -0
- interposition-0.1.0/src/interposition/services.py +53 -0
- interposition-0.1.0/src/interposition.egg-info/PKG-INFO +233 -0
- interposition-0.1.0/src/interposition.egg-info/SOURCES.txt +14 -0
- interposition-0.1.0/src/interposition.egg-info/dependency_links.txt +1 -0
- interposition-0.1.0/src/interposition.egg-info/requires.txt +1 -0
- interposition-0.1.0/src/interposition.egg-info/top_level.txt +1 -0
|
@@ -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,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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pydantic<3.0,>=2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
interposition
|