stubsmith 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.
- stubsmith-0.1.0/LICENSE +21 -0
- stubsmith-0.1.0/PKG-INFO +318 -0
- stubsmith-0.1.0/README.md +274 -0
- stubsmith-0.1.0/pyproject.toml +76 -0
- stubsmith-0.1.0/setup.cfg +4 -0
- stubsmith-0.1.0/stubsmith/__init__.py +87 -0
- stubsmith-0.1.0/stubsmith/__main__.py +15 -0
- stubsmith-0.1.0/stubsmith/_replay_state.py +43 -0
- stubsmith-0.1.0/stubsmith/_version.py +4 -0
- stubsmith-0.1.0/stubsmith/cli.py +439 -0
- stubsmith-0.1.0/stubsmith/client.py +784 -0
- stubsmith-0.1.0/stubsmith/fixtures.py +365 -0
- stubsmith-0.1.0/stubsmith/instrument.py +76 -0
- stubsmith-0.1.0/stubsmith/privacy/__init__.py +98 -0
- stubsmith-0.1.0/stubsmith/privacy/binary.py +101 -0
- stubsmith-0.1.0/stubsmith/privacy/field_rules.py +686 -0
- stubsmith-0.1.0/stubsmith/privacy/fingerprint.py +514 -0
- stubsmith-0.1.0/stubsmith/privacy/masking.py +330 -0
- stubsmith-0.1.0/stubsmith/privacy/pipeline.py +361 -0
- stubsmith-0.1.0/stubsmith/privacy/placeholders.py +398 -0
- stubsmith-0.1.0/stubsmith/privacy/rules_cache.py +514 -0
- stubsmith-0.1.0/stubsmith/privacy/templating.py +130 -0
- stubsmith-0.1.0/stubsmith/replay.py +1077 -0
- stubsmith-0.1.0/stubsmith/testing.py +544 -0
- stubsmith-0.1.0/stubsmith.egg-info/PKG-INFO +318 -0
- stubsmith-0.1.0/stubsmith.egg-info/SOURCES.txt +38 -0
- stubsmith-0.1.0/stubsmith.egg-info/dependency_links.txt +1 -0
- stubsmith-0.1.0/stubsmith.egg-info/entry_points.txt +2 -0
- stubsmith-0.1.0/stubsmith.egg-info/requires.txt +21 -0
- stubsmith-0.1.0/stubsmith.egg-info/top_level.txt +1 -0
- stubsmith-0.1.0/tests/test_capture.py +1032 -0
- stubsmith-0.1.0/tests/test_cli.py +692 -0
- stubsmith-0.1.0/tests/test_field_rules_vectors.py +125 -0
- stubsmith-0.1.0/tests/test_fixtures.py +291 -0
- stubsmith-0.1.0/tests/test_pipeline.py +2131 -0
- stubsmith-0.1.0/tests/test_placeholders.py +570 -0
- stubsmith-0.1.0/tests/test_privacy_modules.py +1164 -0
- stubsmith-0.1.0/tests/test_replay.py +1802 -0
- stubsmith-0.1.0/tests/test_testing.py +562 -0
- stubsmith-0.1.0/tests/test_value_types.py +383 -0
stubsmith-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bram
|
|
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.
|
stubsmith-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stubsmith
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: StubSmith Python SDK - instrument outbound HTTP calls for capture & replay
|
|
5
|
+
Author: Stubsmith
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://stubsmith.dev
|
|
8
|
+
Project-URL: Documentation, https://docs.stubsmith.dev
|
|
9
|
+
Project-URL: Guides, https://stubsmith.dev/guides
|
|
10
|
+
Project-URL: Source, https://github.com/Stubsmith/stubsmith-python
|
|
11
|
+
Project-URL: Issues, https://github.com/Stubsmith/stubsmith-python/issues
|
|
12
|
+
Keywords: testing,http,mocking,fixtures,replay,vcr
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: requests
|
|
28
|
+
Requires-Dist: requests>=2.20; extra == "requests"
|
|
29
|
+
Provides-Extra: httpx
|
|
30
|
+
Requires-Dist: httpx>=0.23; extra == "httpx"
|
|
31
|
+
Provides-Extra: privacy
|
|
32
|
+
Requires-Dist: cryptography>=41; extra == "privacy"
|
|
33
|
+
Provides-Extra: testing
|
|
34
|
+
Requires-Dist: responses>=0.23; extra == "testing"
|
|
35
|
+
Provides-Extra: test
|
|
36
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
37
|
+
Requires-Dist: responses>=0.23; extra == "test"
|
|
38
|
+
Requires-Dist: respx>=0.20; extra == "test"
|
|
39
|
+
Requires-Dist: requests>=2.20; extra == "test"
|
|
40
|
+
Requires-Dist: httpx>=0.23; extra == "test"
|
|
41
|
+
Requires-Dist: anyio[trio]>=3; extra == "test"
|
|
42
|
+
Requires-Dist: cryptography>=41; extra == "test"
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# StubSmith Python SDK
|
|
46
|
+
|
|
47
|
+
Instrument **outbound** HTTP calls made by your Python application and forward
|
|
48
|
+
them, request **and** response, to the StubSmith ingest service for capture,
|
|
49
|
+
anonymization, and replay.
|
|
50
|
+
|
|
51
|
+
Supports both `requests` and `httpx` (sync and async). Sending is
|
|
52
|
+
non-blocking and fire-and-forget: a background daemon thread drains a bounded
|
|
53
|
+
queue; any failure (network, serialization, queue overflow) is silently
|
|
54
|
+
discarded and never propagates to your application.
|
|
55
|
+
|
|
56
|
+
Anonymization / masking is applied **server-side** by the Go ingest service
|
|
57
|
+
(`ingest-go`) - do not pre-mask data client-side.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# with requests support
|
|
65
|
+
pip install "stubsmith[requests]"
|
|
66
|
+
|
|
67
|
+
# with httpx support
|
|
68
|
+
pip install "stubsmith[httpx]"
|
|
69
|
+
|
|
70
|
+
# both
|
|
71
|
+
pip install "stubsmith[requests,httpx]"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quickstart
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import stubsmith
|
|
80
|
+
|
|
81
|
+
# One-liner: instruments both requests and httpx (whichever is importable)
|
|
82
|
+
client = stubsmith.install(api_key="sk-your-project-key")
|
|
83
|
+
|
|
84
|
+
# Now every outbound call is captured automatically:
|
|
85
|
+
import requests
|
|
86
|
+
resp = requests.get("https://api.stripe.com/v1/charges")
|
|
87
|
+
|
|
88
|
+
import httpx
|
|
89
|
+
resp = httpx.get("https://api.example.com/users")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Or configure via environment variables and call `install()` with no arguments:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export STUBSMITH_URL="http://ingest:8081/v1/captures"
|
|
96
|
+
export STUBSMITH_API_KEY="sk-your-project-key"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import stubsmith
|
|
101
|
+
stubsmith.install()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Selective instrumentation
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from stubsmith import StubSmith
|
|
110
|
+
|
|
111
|
+
client = StubSmith(url="...", api_key="sk-...")
|
|
112
|
+
client.instrument_requests() # only requests
|
|
113
|
+
# client.instrument_httpx() # only httpx
|
|
114
|
+
|
|
115
|
+
# Remove patches later:
|
|
116
|
+
client.uninstrument()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Configuration
|
|
122
|
+
|
|
123
|
+
| Parameter | Default | Description |
|
|
124
|
+
|------------------|--------------------------------|---------------------------------------------------------------|
|
|
125
|
+
| `url` | `$STUBSMITH_URL` / `https://ingest.stubsmith.dev/v1/captures` | Full URL of the ingest endpoint |
|
|
126
|
+
| `api_key` | `$STUBSMITH_API_KEY` | Bearer token; empty value auto-disables the client |
|
|
127
|
+
| `enabled` | `True` | Master switch (also auto-disabled when `api_key` is absent) |
|
|
128
|
+
| `timeout` | `5` (seconds) | HTTP timeout for ingest POST |
|
|
129
|
+
| `max_body_bytes` | `65536` (64 KiB) | Truncate captured bodies to this size before sending |
|
|
130
|
+
| `sample_rate` | `1.0` | Fraction of calls to forward (0.0 - 1.0) |
|
|
131
|
+
| `queue_maxsize` | `1000` | Bound on the background queue; excess items are dropped |
|
|
132
|
+
| `flush_timeout` | `$STUBSMITH_FLUSH_TIMEOUT` / `1.0` (seconds) | How long process exit waits for queued captures to drain. `0` disables the wait |
|
|
133
|
+
|
|
134
|
+
### An outage cannot block your application
|
|
135
|
+
|
|
136
|
+
Captures are masked on the calling thread (CPU only, no I/O) and handed to a
|
|
137
|
+
bounded queue with a non-blocking `put`. A daemon thread does the HTTP POST and
|
|
138
|
+
swallows every exception. If the ingest service is slow, unreachable or
|
|
139
|
+
returning errors, your request path is unaffected: the queue fills, excess
|
|
140
|
+
captures are dropped, and nothing propagates to your code.
|
|
141
|
+
|
|
142
|
+
Measured with the ingest host blackholed, so every POST runs to its timeout: a
|
|
143
|
+
median of 0.32 ms and a p95 of 0.45 ms added to an instrumented call.
|
|
144
|
+
|
|
145
|
+
The one place an outage is visible is process exit, where an `atexit` hook waits
|
|
146
|
+
for the queue to drain. That wait is capped at `flush_timeout`, and is abandoned
|
|
147
|
+
as soon as a send fails, so an unreachable endpoint costs about a second rather
|
|
148
|
+
than the full budget. Set `STUBSMITH_FLUSH_TIMEOUT=0` in a serverless function or
|
|
149
|
+
anywhere else exit latency is billed; captures still in the queue are discarded.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## How it works
|
|
154
|
+
|
|
155
|
+
1. `install()` / `instrument_requests()` / `instrument_httpx()` monkey-patches
|
|
156
|
+
the relevant HTTP client (idempotent; safe to call multiple times).
|
|
157
|
+
2. Each call is timed; request headers/body and response status/headers/body
|
|
158
|
+
are captured **without consuming streams** - if you opened a streaming
|
|
159
|
+
response the SDK skips the body rather than interfering.
|
|
160
|
+
3. Captures are placed on an in-process `queue.Queue`; a daemon thread drains
|
|
161
|
+
it and POSTs to `POST /v1/captures` with `Authorization: Bearer <api_key>`.
|
|
162
|
+
4. On process exit an `atexit` handler flushes the queue (bounded timeout).
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Fingerprint value discrimination
|
|
167
|
+
|
|
168
|
+
By default the SDK fingerprints each request by its body structure (key-paths),
|
|
169
|
+
query parameter names, and Content-Type. Endpoints that multiplex operations via a
|
|
170
|
+
body field (e.g. `{"action": "login"}` vs `{"action": "delete_user"}`) therefore
|
|
171
|
+
produce a single fingerprint, which means a single review and a single privacy-rule
|
|
172
|
+
set for both variants.
|
|
173
|
+
|
|
174
|
+
Enable value discrimination on `action` in the StubSmith UI or via the API and the
|
|
175
|
+
SDK will automatically include that field's value in the hash:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
# No SDK change required - configure via the UI or API, then the SDK picks up the
|
|
179
|
+
# new value paths on its next rules-sync poll (default: every 60 seconds).
|
|
180
|
+
import stubsmith
|
|
181
|
+
stubsmith.install(url="...", api_key="sk-...")
|
|
182
|
+
|
|
183
|
+
import requests
|
|
184
|
+
requests.post("https://api.example.com/rpc", json={"action": "login", "username": "alice"})
|
|
185
|
+
# → fingerprint A (action=login)
|
|
186
|
+
|
|
187
|
+
requests.post("https://api.example.com/rpc", json={"action": "delete_user", "user_id": 42})
|
|
188
|
+
# → fingerprint B (action=delete_user) - separate review, separate rules
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
See [`docs/fingerprint-value-discrimination.md`](../../docs/fingerprint-value-discrimination.md)
|
|
192
|
+
for a full walkthrough including the hash mechanics, all three configuration methods,
|
|
193
|
+
and the privacy guarantees.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Offline replay in tests
|
|
198
|
+
|
|
199
|
+
`stubsmith.replay()` serves recorded responses to your code's outbound HTTP
|
|
200
|
+
calls. Inside the block no network call is made and the dependency does not
|
|
201
|
+
need to be running.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import stubsmith
|
|
205
|
+
|
|
206
|
+
def test_charge_is_declined():
|
|
207
|
+
with stubsmith.replay():
|
|
208
|
+
with pytest.raises(CardDeclined):
|
|
209
|
+
PaymentClient().charge(amount_cents=950_000, currency="USD")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Fetch the bundle once and commit it:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
export STUBSMITH_API_KEY=<your project key>
|
|
216
|
+
stubsmith pull --out .stubsmith/bundle.json
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`python -m stubsmith pull` does the same thing. It resolves the package from
|
|
220
|
+
`sys.path`, so it runs a checkout without installing it, and it cannot pick up a
|
|
221
|
+
stale installed copy in place of the one you are working on.
|
|
222
|
+
|
|
223
|
+
From then on the test suite needs no key, no network and no `pull` step - it
|
|
224
|
+
reads the committed file. Refresh it when the recording should change: the
|
|
225
|
+
upstream API's shape changed, you added a call the bundle does not cover, or you
|
|
226
|
+
approved new fingerprints. Treat it like a lockfile or a golden file: an
|
|
227
|
+
occasional, reviewed, committed change.
|
|
228
|
+
|
|
229
|
+
`replay()` finds the bundle without configuration - an explicit path, then
|
|
230
|
+
`$STUBSMITH_BUNDLE`, then an upward search from the working directory that stops
|
|
231
|
+
at the first directory containing `.git` or `pyproject.toml`. Pass a path or a
|
|
232
|
+
dict to be explicit:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
with stubsmith.replay("tests/data/bundle.json"):
|
|
236
|
+
...
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Matching
|
|
240
|
+
|
|
241
|
+
A request is matched on `(domain, method, path_template, fingerprint)`. The
|
|
242
|
+
fingerprint covers body key-paths, query parameter names and the normalised
|
|
243
|
+
content-type - not values, and not the host or path, which is why the other
|
|
244
|
+
three parts of the key are needed. Every body-less `GET` shares one
|
|
245
|
+
fingerprint.
|
|
246
|
+
|
|
247
|
+
Dynamic path segments are templated from the recording, so `/api/users/4821`
|
|
248
|
+
matches a stub recorded as `/api/users/{id}`.
|
|
249
|
+
|
|
250
|
+
### When nothing matches
|
|
251
|
+
|
|
252
|
+
`StubNotFound` is raised with a diff of what was sent against the closest
|
|
253
|
+
recording, naming the fields that differ. It never falls through to the
|
|
254
|
+
network, so a test cannot silently start calling a real service.
|
|
255
|
+
|
|
256
|
+
A stub whose fingerprint has no recorded captures is reported as `degraded` by
|
|
257
|
+
`stubsmith pull` and raises the same error at replay time, rather than serving an
|
|
258
|
+
empty response.
|
|
259
|
+
|
|
260
|
+
See `examples/fixtures-testing/` for a complete worked example: a real service, a
|
|
261
|
+
client instrumented with the SDK, traffic captured and reviewed, and a test suite
|
|
262
|
+
that passes with the service stopped.
|
|
263
|
+
|
|
264
|
+
### Single-fixture helpers
|
|
265
|
+
|
|
266
|
+
`stubsmith.testing` handles individual fixture files rather than a whole bundle,
|
|
267
|
+
for cases where you want one recorded exchange registered against `responses`:
|
|
268
|
+
|
|
269
|
+
```sh
|
|
270
|
+
pip install "stubsmith[testing]"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from stubsmith import testing
|
|
275
|
+
|
|
276
|
+
bundle = testing.load_bundle("fixtures/get_user.json")
|
|
277
|
+
testing.register_template(responses, bundle, base_url="http://api")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
It also provides `assert_request_matches_fixture` and
|
|
281
|
+
`assert_body_schemas_match` as contract guards. For a normal test suite
|
|
282
|
+
`replay()` is the simpler path - it covers every recorded endpoint at once and
|
|
283
|
+
needs no per-fixture registration.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Masking and placeholders
|
|
288
|
+
|
|
289
|
+
Values are masked in the SDK, before a capture is uploaded: the server never
|
|
290
|
+
receives the originals. A field with no `keep` rule is replaced.
|
|
291
|
+
|
|
292
|
+
By default the replacement is a constant (`"<masked>"`, `0`, `False`) matching
|
|
293
|
+
the original's type.
|
|
294
|
+
|
|
295
|
+
Setting `STUBSMITH_MASK_SALT` to any non-empty string switches on
|
|
296
|
+
format-preserving placeholders for fields carrying a semantic type hint
|
|
297
|
+
(`email`, `uuid`, `iso8601`, `e164`, `iban`, `url`, `decimal_amount`,
|
|
298
|
+
`integer_id`, `opaque_token`, `free_text`). The replacement then has the same
|
|
299
|
+
*shape* as the original - a parseable timestamp, an RFC 4122 UUID, an IBAN with a
|
|
300
|
+
correct mod-97 checksum - so code that parses or validates these values still
|
|
301
|
+
works against a recording. The same salt and value always produce the same
|
|
302
|
+
placeholder, which preserves uniqueness and cross-field references. The salt
|
|
303
|
+
never leaves the process.
|
|
304
|
+
|
|
305
|
+
Two types are never format-preserved, regardless of salt: `currency_code` and
|
|
306
|
+
`country_code`. Booleans are refused for the same reason. Their domains are
|
|
307
|
+
small enough that a keyed hash could be reversed with a lookup table. Use
|
|
308
|
+
`action: keep` for those fields instead - they are rarely sensitive.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Development / tests
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
316
|
+
pip install -e '.[test]'
|
|
317
|
+
python -m pytest -q
|
|
318
|
+
```
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# StubSmith Python SDK
|
|
2
|
+
|
|
3
|
+
Instrument **outbound** HTTP calls made by your Python application and forward
|
|
4
|
+
them, request **and** response, to the StubSmith ingest service for capture,
|
|
5
|
+
anonymization, and replay.
|
|
6
|
+
|
|
7
|
+
Supports both `requests` and `httpx` (sync and async). Sending is
|
|
8
|
+
non-blocking and fire-and-forget: a background daemon thread drains a bounded
|
|
9
|
+
queue; any failure (network, serialization, queue overflow) is silently
|
|
10
|
+
discarded and never propagates to your application.
|
|
11
|
+
|
|
12
|
+
Anonymization / masking is applied **server-side** by the Go ingest service
|
|
13
|
+
(`ingest-go`) - do not pre-mask data client-side.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# with requests support
|
|
21
|
+
pip install "stubsmith[requests]"
|
|
22
|
+
|
|
23
|
+
# with httpx support
|
|
24
|
+
pip install "stubsmith[httpx]"
|
|
25
|
+
|
|
26
|
+
# both
|
|
27
|
+
pip install "stubsmith[requests,httpx]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import stubsmith
|
|
36
|
+
|
|
37
|
+
# One-liner: instruments both requests and httpx (whichever is importable)
|
|
38
|
+
client = stubsmith.install(api_key="sk-your-project-key")
|
|
39
|
+
|
|
40
|
+
# Now every outbound call is captured automatically:
|
|
41
|
+
import requests
|
|
42
|
+
resp = requests.get("https://api.stripe.com/v1/charges")
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
resp = httpx.get("https://api.example.com/users")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or configure via environment variables and call `install()` with no arguments:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export STUBSMITH_URL="http://ingest:8081/v1/captures"
|
|
52
|
+
export STUBSMITH_API_KEY="sk-your-project-key"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import stubsmith
|
|
57
|
+
stubsmith.install()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Selective instrumentation
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from stubsmith import StubSmith
|
|
66
|
+
|
|
67
|
+
client = StubSmith(url="...", api_key="sk-...")
|
|
68
|
+
client.instrument_requests() # only requests
|
|
69
|
+
# client.instrument_httpx() # only httpx
|
|
70
|
+
|
|
71
|
+
# Remove patches later:
|
|
72
|
+
client.uninstrument()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
| Parameter | Default | Description |
|
|
80
|
+
|------------------|--------------------------------|---------------------------------------------------------------|
|
|
81
|
+
| `url` | `$STUBSMITH_URL` / `https://ingest.stubsmith.dev/v1/captures` | Full URL of the ingest endpoint |
|
|
82
|
+
| `api_key` | `$STUBSMITH_API_KEY` | Bearer token; empty value auto-disables the client |
|
|
83
|
+
| `enabled` | `True` | Master switch (also auto-disabled when `api_key` is absent) |
|
|
84
|
+
| `timeout` | `5` (seconds) | HTTP timeout for ingest POST |
|
|
85
|
+
| `max_body_bytes` | `65536` (64 KiB) | Truncate captured bodies to this size before sending |
|
|
86
|
+
| `sample_rate` | `1.0` | Fraction of calls to forward (0.0 - 1.0) |
|
|
87
|
+
| `queue_maxsize` | `1000` | Bound on the background queue; excess items are dropped |
|
|
88
|
+
| `flush_timeout` | `$STUBSMITH_FLUSH_TIMEOUT` / `1.0` (seconds) | How long process exit waits for queued captures to drain. `0` disables the wait |
|
|
89
|
+
|
|
90
|
+
### An outage cannot block your application
|
|
91
|
+
|
|
92
|
+
Captures are masked on the calling thread (CPU only, no I/O) and handed to a
|
|
93
|
+
bounded queue with a non-blocking `put`. A daemon thread does the HTTP POST and
|
|
94
|
+
swallows every exception. If the ingest service is slow, unreachable or
|
|
95
|
+
returning errors, your request path is unaffected: the queue fills, excess
|
|
96
|
+
captures are dropped, and nothing propagates to your code.
|
|
97
|
+
|
|
98
|
+
Measured with the ingest host blackholed, so every POST runs to its timeout: a
|
|
99
|
+
median of 0.32 ms and a p95 of 0.45 ms added to an instrumented call.
|
|
100
|
+
|
|
101
|
+
The one place an outage is visible is process exit, where an `atexit` hook waits
|
|
102
|
+
for the queue to drain. That wait is capped at `flush_timeout`, and is abandoned
|
|
103
|
+
as soon as a send fails, so an unreachable endpoint costs about a second rather
|
|
104
|
+
than the full budget. Set `STUBSMITH_FLUSH_TIMEOUT=0` in a serverless function or
|
|
105
|
+
anywhere else exit latency is billed; captures still in the queue are discarded.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## How it works
|
|
110
|
+
|
|
111
|
+
1. `install()` / `instrument_requests()` / `instrument_httpx()` monkey-patches
|
|
112
|
+
the relevant HTTP client (idempotent; safe to call multiple times).
|
|
113
|
+
2. Each call is timed; request headers/body and response status/headers/body
|
|
114
|
+
are captured **without consuming streams** - if you opened a streaming
|
|
115
|
+
response the SDK skips the body rather than interfering.
|
|
116
|
+
3. Captures are placed on an in-process `queue.Queue`; a daemon thread drains
|
|
117
|
+
it and POSTs to `POST /v1/captures` with `Authorization: Bearer <api_key>`.
|
|
118
|
+
4. On process exit an `atexit` handler flushes the queue (bounded timeout).
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Fingerprint value discrimination
|
|
123
|
+
|
|
124
|
+
By default the SDK fingerprints each request by its body structure (key-paths),
|
|
125
|
+
query parameter names, and Content-Type. Endpoints that multiplex operations via a
|
|
126
|
+
body field (e.g. `{"action": "login"}` vs `{"action": "delete_user"}`) therefore
|
|
127
|
+
produce a single fingerprint, which means a single review and a single privacy-rule
|
|
128
|
+
set for both variants.
|
|
129
|
+
|
|
130
|
+
Enable value discrimination on `action` in the StubSmith UI or via the API and the
|
|
131
|
+
SDK will automatically include that field's value in the hash:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# No SDK change required - configure via the UI or API, then the SDK picks up the
|
|
135
|
+
# new value paths on its next rules-sync poll (default: every 60 seconds).
|
|
136
|
+
import stubsmith
|
|
137
|
+
stubsmith.install(url="...", api_key="sk-...")
|
|
138
|
+
|
|
139
|
+
import requests
|
|
140
|
+
requests.post("https://api.example.com/rpc", json={"action": "login", "username": "alice"})
|
|
141
|
+
# → fingerprint A (action=login)
|
|
142
|
+
|
|
143
|
+
requests.post("https://api.example.com/rpc", json={"action": "delete_user", "user_id": 42})
|
|
144
|
+
# → fingerprint B (action=delete_user) - separate review, separate rules
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
See [`docs/fingerprint-value-discrimination.md`](../../docs/fingerprint-value-discrimination.md)
|
|
148
|
+
for a full walkthrough including the hash mechanics, all three configuration methods,
|
|
149
|
+
and the privacy guarantees.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Offline replay in tests
|
|
154
|
+
|
|
155
|
+
`stubsmith.replay()` serves recorded responses to your code's outbound HTTP
|
|
156
|
+
calls. Inside the block no network call is made and the dependency does not
|
|
157
|
+
need to be running.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import stubsmith
|
|
161
|
+
|
|
162
|
+
def test_charge_is_declined():
|
|
163
|
+
with stubsmith.replay():
|
|
164
|
+
with pytest.raises(CardDeclined):
|
|
165
|
+
PaymentClient().charge(amount_cents=950_000, currency="USD")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Fetch the bundle once and commit it:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
export STUBSMITH_API_KEY=<your project key>
|
|
172
|
+
stubsmith pull --out .stubsmith/bundle.json
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`python -m stubsmith pull` does the same thing. It resolves the package from
|
|
176
|
+
`sys.path`, so it runs a checkout without installing it, and it cannot pick up a
|
|
177
|
+
stale installed copy in place of the one you are working on.
|
|
178
|
+
|
|
179
|
+
From then on the test suite needs no key, no network and no `pull` step - it
|
|
180
|
+
reads the committed file. Refresh it when the recording should change: the
|
|
181
|
+
upstream API's shape changed, you added a call the bundle does not cover, or you
|
|
182
|
+
approved new fingerprints. Treat it like a lockfile or a golden file: an
|
|
183
|
+
occasional, reviewed, committed change.
|
|
184
|
+
|
|
185
|
+
`replay()` finds the bundle without configuration - an explicit path, then
|
|
186
|
+
`$STUBSMITH_BUNDLE`, then an upward search from the working directory that stops
|
|
187
|
+
at the first directory containing `.git` or `pyproject.toml`. Pass a path or a
|
|
188
|
+
dict to be explicit:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
with stubsmith.replay("tests/data/bundle.json"):
|
|
192
|
+
...
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Matching
|
|
196
|
+
|
|
197
|
+
A request is matched on `(domain, method, path_template, fingerprint)`. The
|
|
198
|
+
fingerprint covers body key-paths, query parameter names and the normalised
|
|
199
|
+
content-type - not values, and not the host or path, which is why the other
|
|
200
|
+
three parts of the key are needed. Every body-less `GET` shares one
|
|
201
|
+
fingerprint.
|
|
202
|
+
|
|
203
|
+
Dynamic path segments are templated from the recording, so `/api/users/4821`
|
|
204
|
+
matches a stub recorded as `/api/users/{id}`.
|
|
205
|
+
|
|
206
|
+
### When nothing matches
|
|
207
|
+
|
|
208
|
+
`StubNotFound` is raised with a diff of what was sent against the closest
|
|
209
|
+
recording, naming the fields that differ. It never falls through to the
|
|
210
|
+
network, so a test cannot silently start calling a real service.
|
|
211
|
+
|
|
212
|
+
A stub whose fingerprint has no recorded captures is reported as `degraded` by
|
|
213
|
+
`stubsmith pull` and raises the same error at replay time, rather than serving an
|
|
214
|
+
empty response.
|
|
215
|
+
|
|
216
|
+
See `examples/fixtures-testing/` for a complete worked example: a real service, a
|
|
217
|
+
client instrumented with the SDK, traffic captured and reviewed, and a test suite
|
|
218
|
+
that passes with the service stopped.
|
|
219
|
+
|
|
220
|
+
### Single-fixture helpers
|
|
221
|
+
|
|
222
|
+
`stubsmith.testing` handles individual fixture files rather than a whole bundle,
|
|
223
|
+
for cases where you want one recorded exchange registered against `responses`:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
pip install "stubsmith[testing]"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from stubsmith import testing
|
|
231
|
+
|
|
232
|
+
bundle = testing.load_bundle("fixtures/get_user.json")
|
|
233
|
+
testing.register_template(responses, bundle, base_url="http://api")
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
It also provides `assert_request_matches_fixture` and
|
|
237
|
+
`assert_body_schemas_match` as contract guards. For a normal test suite
|
|
238
|
+
`replay()` is the simpler path - it covers every recorded endpoint at once and
|
|
239
|
+
needs no per-fixture registration.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Masking and placeholders
|
|
244
|
+
|
|
245
|
+
Values are masked in the SDK, before a capture is uploaded: the server never
|
|
246
|
+
receives the originals. A field with no `keep` rule is replaced.
|
|
247
|
+
|
|
248
|
+
By default the replacement is a constant (`"<masked>"`, `0`, `False`) matching
|
|
249
|
+
the original's type.
|
|
250
|
+
|
|
251
|
+
Setting `STUBSMITH_MASK_SALT` to any non-empty string switches on
|
|
252
|
+
format-preserving placeholders for fields carrying a semantic type hint
|
|
253
|
+
(`email`, `uuid`, `iso8601`, `e164`, `iban`, `url`, `decimal_amount`,
|
|
254
|
+
`integer_id`, `opaque_token`, `free_text`). The replacement then has the same
|
|
255
|
+
*shape* as the original - a parseable timestamp, an RFC 4122 UUID, an IBAN with a
|
|
256
|
+
correct mod-97 checksum - so code that parses or validates these values still
|
|
257
|
+
works against a recording. The same salt and value always produce the same
|
|
258
|
+
placeholder, which preserves uniqueness and cross-field references. The salt
|
|
259
|
+
never leaves the process.
|
|
260
|
+
|
|
261
|
+
Two types are never format-preserved, regardless of salt: `currency_code` and
|
|
262
|
+
`country_code`. Booleans are refused for the same reason. Their domains are
|
|
263
|
+
small enough that a keyed hash could be reversed with a lookup table. Use
|
|
264
|
+
`action: keep` for those fields instead - they are rarely sensitive.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Development / tests
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
272
|
+
pip install -e '.[test]'
|
|
273
|
+
python -m pytest -q
|
|
274
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stubsmith"
|
|
7
|
+
# Read from stubsmith/_version.py, the single source of truth also used by the
|
|
8
|
+
# SDK's User-Agent. A hardcoded literal here had already drifted behind it.
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "StubSmith Python SDK - instrument outbound HTTP calls for capture & replay"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
# 3.8 has been end-of-life since October 2024 and was never exercised by CI, so
|
|
14
|
+
# claiming it was an untested promise. The floor is what the matrix proves.
|
|
15
|
+
requires-python = ">=3.9"
|
|
16
|
+
dependencies = []
|
|
17
|
+
authors = [{ name = "Stubsmith" }]
|
|
18
|
+
keywords = ["testing", "http", "mocking", "fixtures", "replay", "vcr"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Software Development :: Testing",
|
|
30
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://stubsmith.dev"
|
|
35
|
+
Documentation = "https://docs.stubsmith.dev"
|
|
36
|
+
Guides = "https://stubsmith.dev/guides"
|
|
37
|
+
Source = "https://github.com/Stubsmith/stubsmith-python"
|
|
38
|
+
Issues = "https://github.com/Stubsmith/stubsmith-python/issues"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
requests = ["requests>=2.20"]
|
|
42
|
+
httpx = ["httpx>=0.23"]
|
|
43
|
+
privacy = ["cryptography>=41"]
|
|
44
|
+
testing = ["responses>=0.23"]
|
|
45
|
+
test = [
|
|
46
|
+
"pytest>=7",
|
|
47
|
+
"responses>=0.23",
|
|
48
|
+
"respx>=0.20",
|
|
49
|
+
"requests>=2.20",
|
|
50
|
+
"httpx>=0.23",
|
|
51
|
+
"anyio[trio]>=3",
|
|
52
|
+
"cryptography>=41",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[project.scripts]
|
|
56
|
+
stubsmith = "stubsmith.cli:_cli_entry"
|
|
57
|
+
|
|
58
|
+
[tool.setuptools.dynamic]
|
|
59
|
+
version = { attr = "stubsmith._version.__version__" }
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["."]
|
|
63
|
+
include = ["stubsmith*"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
# tests/ - the SDK's own unit tests; always hermetic (no API key needed).
|
|
67
|
+
# examples/fixtures-testing/tests/
|
|
68
|
+
# - the ported fixtures-testing example tests; hermetic (offline
|
|
69
|
+
# tests use vendored JSON; live tests are skipped without a key).
|
|
70
|
+
# examples/python/test_charge_fixtures.py is NOT listed here: it calls
|
|
71
|
+
# stubsmith.fixtures() at module import, which requires STUBSMITH_API_KEY and a
|
|
72
|
+
# live backend, so it fails at collection without them.
|
|
73
|
+
testpaths = ["tests", "examples/fixtures-testing/tests"]
|
|
74
|
+
markers = [
|
|
75
|
+
"live: tests that call the real Stubsmith backend (require STUBSMITH_API_KEY)",
|
|
76
|
+
]
|