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.
Files changed (40) hide show
  1. stubsmith-0.1.0/LICENSE +21 -0
  2. stubsmith-0.1.0/PKG-INFO +318 -0
  3. stubsmith-0.1.0/README.md +274 -0
  4. stubsmith-0.1.0/pyproject.toml +76 -0
  5. stubsmith-0.1.0/setup.cfg +4 -0
  6. stubsmith-0.1.0/stubsmith/__init__.py +87 -0
  7. stubsmith-0.1.0/stubsmith/__main__.py +15 -0
  8. stubsmith-0.1.0/stubsmith/_replay_state.py +43 -0
  9. stubsmith-0.1.0/stubsmith/_version.py +4 -0
  10. stubsmith-0.1.0/stubsmith/cli.py +439 -0
  11. stubsmith-0.1.0/stubsmith/client.py +784 -0
  12. stubsmith-0.1.0/stubsmith/fixtures.py +365 -0
  13. stubsmith-0.1.0/stubsmith/instrument.py +76 -0
  14. stubsmith-0.1.0/stubsmith/privacy/__init__.py +98 -0
  15. stubsmith-0.1.0/stubsmith/privacy/binary.py +101 -0
  16. stubsmith-0.1.0/stubsmith/privacy/field_rules.py +686 -0
  17. stubsmith-0.1.0/stubsmith/privacy/fingerprint.py +514 -0
  18. stubsmith-0.1.0/stubsmith/privacy/masking.py +330 -0
  19. stubsmith-0.1.0/stubsmith/privacy/pipeline.py +361 -0
  20. stubsmith-0.1.0/stubsmith/privacy/placeholders.py +398 -0
  21. stubsmith-0.1.0/stubsmith/privacy/rules_cache.py +514 -0
  22. stubsmith-0.1.0/stubsmith/privacy/templating.py +130 -0
  23. stubsmith-0.1.0/stubsmith/replay.py +1077 -0
  24. stubsmith-0.1.0/stubsmith/testing.py +544 -0
  25. stubsmith-0.1.0/stubsmith.egg-info/PKG-INFO +318 -0
  26. stubsmith-0.1.0/stubsmith.egg-info/SOURCES.txt +38 -0
  27. stubsmith-0.1.0/stubsmith.egg-info/dependency_links.txt +1 -0
  28. stubsmith-0.1.0/stubsmith.egg-info/entry_points.txt +2 -0
  29. stubsmith-0.1.0/stubsmith.egg-info/requires.txt +21 -0
  30. stubsmith-0.1.0/stubsmith.egg-info/top_level.txt +1 -0
  31. stubsmith-0.1.0/tests/test_capture.py +1032 -0
  32. stubsmith-0.1.0/tests/test_cli.py +692 -0
  33. stubsmith-0.1.0/tests/test_field_rules_vectors.py +125 -0
  34. stubsmith-0.1.0/tests/test_fixtures.py +291 -0
  35. stubsmith-0.1.0/tests/test_pipeline.py +2131 -0
  36. stubsmith-0.1.0/tests/test_placeholders.py +570 -0
  37. stubsmith-0.1.0/tests/test_privacy_modules.py +1164 -0
  38. stubsmith-0.1.0/tests/test_replay.py +1802 -0
  39. stubsmith-0.1.0/tests/test_testing.py +562 -0
  40. stubsmith-0.1.0/tests/test_value_types.py +383 -0
@@ -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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+