stubsmith 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ StubSmith Python SDK.
3
+
4
+ Quickstart - capture instrumentation::
5
+
6
+ import stubsmith
7
+
8
+ client = stubsmith.install(api_key="sk-your-project-key")
9
+
10
+ ``install`` instruments both ``requests`` and ``httpx`` (whichever is
11
+ importable) so every outbound HTTP call is captured, privacy-processed, and
12
+ forwarded to the StubSmith ingest service in the background. Sending is
13
+ non-blocking and fire-and-forget; any failure is silently swallowed.
14
+
15
+ Privacy / masking
16
+ -----------------
17
+ Anonymisation is applied **client-side at the edge** - inside this SDK
18
+ process - before any data is transmitted. Raw field values never cross the
19
+ process boundary.
20
+
21
+ The pipeline:
22
+
23
+ 1. Replaces ``image/*`` bodies with canonical 1×1 placeholders (pixel data
24
+ and EXIF metadata can carry PII).
25
+ 2. Fingerprints the request body key-paths, query parameter names, and
26
+ content-type to produce a stable structural identity.
27
+ 3. Looks up per-fingerprint field rules synced from the backend
28
+ (``GET /v1/sdk/sync``). Unknown fingerprints are masked entirely
29
+ (fail-closed) and flagged ``novel=True``.
30
+ 4. Applies field rules (keep/mask decisions) for known fingerprints, plus
31
+ a belt-and-suspenders regex pass on remaining string values.
32
+
33
+ Quickstart - fetch fixtures for testing::
34
+
35
+ import stubsmith
36
+
37
+ fxs = stubsmith.fixtures("POST /v1/charges/{id}", distinct="status")
38
+ for fx in fxs:
39
+ data = fx.response.json() # parsed response body
40
+ print(fx.status, data)
41
+
42
+ # Full envelope with request_type metadata (path_pattern, is_dynamic):
43
+ bundle = stubsmith.fixtures_bundle("GET /v1/users/{id}", distinct="status")
44
+ print(bundle.request_type) # {"id": ..., "method": "GET", "path_pattern": ..., "is_dynamic": True}
45
+ fx_200 = bundle.by_status(200)
46
+
47
+ Set ``STUBSMITH_API_URL`` and ``STUBSMITH_API_KEY`` before calling
48
+ :func:`fixtures` or :func:`fixtures_bundle`, or pass them as keyword arguments.
49
+
50
+ Quickstart - use fixtures as test stubs::
51
+
52
+ from stubsmith import testing
53
+ import responses
54
+
55
+ @responses.activate
56
+ def test_get_user():
57
+ bundle = testing.load_bundle("fixtures/get_user.json")
58
+ testing.register_template(responses, bundle, base_url="http://api")
59
+ result = client.get_user(99)
60
+ assert result["id"] == 99
61
+
62
+ :mod:`stubsmith.testing` is not imported here to keep ``import stubsmith``
63
+ free of the optional ``responses`` dependency.
64
+ """
65
+
66
+ from .client import StubSmith, _DEFAULT_URL as DEFAULT_INGEST_URL, _DEFAULT_BACKEND_URL as DEFAULT_API_URL
67
+ from .fixtures import Fixture, FixtureBundle, fixtures, fixtures_bundle
68
+ from .instrument import install
69
+ from .privacy.pipeline import PrivacyPipeline
70
+ from .replay import ReplayContext, StubNotFound, replay
71
+
72
+ __all__ = [
73
+ "StubSmith",
74
+ "DEFAULT_INGEST_URL",
75
+ "DEFAULT_API_URL",
76
+ "Fixture",
77
+ "FixtureBundle",
78
+ "fixtures",
79
+ "fixtures_bundle",
80
+ "install",
81
+ "PrivacyPipeline",
82
+ "ReplayContext",
83
+ "StubNotFound",
84
+ "replay",
85
+ ]
86
+
87
+ from ._version import __version__ # noqa: F401 - re-exported for `stubsmith.__version__`
stubsmith/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Support ``python -m stubsmith`` as an alias for the ``stubsmith`` console script.
2
+
3
+ Both entry points call :func:`stubsmith.cli.main`, so they accept identical
4
+ arguments. ``python -m stubsmith`` resolves the package from ``sys.path``, which
5
+ makes it the reliable way to run a checkout without installing it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from .cli import main
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
@@ -0,0 +1,43 @@
1
+ """
2
+ Shared flag used to suppress capture while stubsmith.replay() is active.
3
+
4
+ Kept in its own module so both ``stubsmith.replay`` and ``stubsmith.client``
5
+ can import it without creating a circular dependency.
6
+
7
+ The counter rather than a boolean makes nested ``replay()`` blocks safe:
8
+ the inner block's ``stop()`` does not clear suppression while the outer
9
+ block is still running.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+
16
+ # The counter is PROCESS-GLOBAL, not thread-local. That matches the scope
17
+ # of the patch: Session.send is a class attribute replaced globally, so
18
+ # every thread sees the stub regardless of which thread called start().
19
+ # A thread-local counter would be incoherent: another thread's
20
+ # _capture_requests would return early even though its send() is serving
21
+ # stubs rather than real responses, or vice versa.
22
+ _lock = threading.Lock()
23
+ _depth: int = 0
24
+
25
+
26
+ def is_replay_active() -> bool:
27
+ """Return True when at least one replay context is active."""
28
+ return _depth > 0
29
+
30
+
31
+ def enter_replay() -> None:
32
+ """Increment the active-replay depth counter (called by ReplayContext.start)."""
33
+ global _depth
34
+ with _lock:
35
+ _depth += 1
36
+
37
+
38
+ def exit_replay() -> None:
39
+ """Decrement the active-replay depth counter (called by ReplayContext.stop)."""
40
+ global _depth
41
+ with _lock:
42
+ if _depth > 0:
43
+ _depth -= 1
stubsmith/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # Single source of truth for the package version.
2
+ # Imported by both stubsmith/__init__.py and stubsmith/client.py (which
3
+ # cannot import from the package root without a circular import).
4
+ __version__ = "0.1.0"
stubsmith/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """
2
+ stubsmith pull - fetch the replay bundle from the StubSmith backend and write
3
+ it to disk so tests can run offline with no API key.
4
+
5
+ Usage::
6
+
7
+ stubsmith pull [--out PATH] [--endpoint "METHOD /path/template"]
8
+
9
+ Environment variables
10
+ ---------------------
11
+ STUBSMITH_API_KEY
12
+ Required. Bearer token for the project.
13
+ STUBSMITH_API_URL
14
+ Base URL of the StubSmith backend. Falls back to ``STUBSMITH_BACKEND_URL``,
15
+ then ``https://app.stubsmith.dev/api``.
16
+
17
+ Determinism
18
+ -----------
19
+ The written file is sorted deterministically so repeated pulls produce no
20
+ spurious diff when nothing has changed on the server. Endpoints are ordered
21
+ by ``(domain, method, path_template)``, stubs by ``fingerprint``, and variants
22
+ by ``status``. ``json.dumps`` is called with ``sort_keys=True`` so every dict
23
+ is key-sorted regardless of insertion order.
24
+
25
+ Collision note
26
+ --------------
27
+ Do NOT collapse the bundle to a fingerprint-hash-only index. A fingerprint
28
+ hashes body key-paths, query-parameter names, and content-type - it does not
29
+ include the host or path. Every body-less GET therefore shares the same hash.
30
+ In one catalog run six endpoints (three ``/avatars/*.png``, ``GET /api/products``,
31
+ ``GET /api/users/{id}``, ``GET /api/orders/{id}``) all carried the hash
32
+ ``fc552c95a0bb0d3e``. The file preserves endpoint → stub nesting exactly so
33
+ that ``replay()`` can key lookups on the full composite
34
+ ``(domain, method, path_template, fingerprint)`` rather than on fingerprint alone.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import pathlib
43
+ import sys
44
+ import tempfile
45
+ import urllib.error
46
+ import urllib.parse
47
+ import urllib.request
48
+ from typing import Any, Dict, List, Optional
49
+
50
+ from ._version import __version__
51
+
52
+ _DEFAULT_OUT = ".stubsmith/bundle.json"
53
+ _DEFAULT_API_URL = "https://app.stubsmith.dev/api"
54
+ _SDK_USER_AGENT = f"stubsmith-cli/{__version__}"
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Internal helpers
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def _resolve_api_url() -> str:
62
+ """Return the backend base URL from the environment.
63
+
64
+ Priority: ``STUBSMITH_API_URL`` > ``STUBSMITH_BACKEND_URL`` >
65
+ ``https://app.stubsmith.dev/api``.
66
+ """
67
+ return (
68
+ os.environ.get("STUBSMITH_API_URL")
69
+ or os.environ.get("STUBSMITH_BACKEND_URL")
70
+ or _DEFAULT_API_URL
71
+ )
72
+
73
+
74
+ def _fetch_bundle(
75
+ api_url: str,
76
+ api_key: str,
77
+ method: Optional[str] = None,
78
+ path: Optional[str] = None,
79
+ ) -> Dict[str, Any]:
80
+ """Fetch ``GET /v1/replay/bundle`` and return the parsed JSON body.
81
+
82
+ Parameters
83
+ ----------
84
+ api_url:
85
+ Backend base URL (no trailing slash).
86
+ api_key:
87
+ Bearer token for the project.
88
+ method:
89
+ Optional HTTP method filter (e.g. ``"GET"``).
90
+ path:
91
+ Optional path-template filter (e.g. ``"/api/users/{id}"``).
92
+ Required when *method* is supplied.
93
+
94
+ Returns
95
+ -------
96
+ dict
97
+ Parsed response body.
98
+
99
+ Raises
100
+ ------
101
+ RuntimeError
102
+ On any HTTP error or network failure.
103
+ ValueError
104
+ When the response body cannot be parsed as JSON.
105
+ """
106
+ params: Dict[str, str] = {}
107
+ if method:
108
+ params["method"] = method
109
+ if path:
110
+ params["path"] = path
111
+
112
+ qs = ("?" + urllib.parse.urlencode(params)) if params else ""
113
+ url = api_url.rstrip("/") + "/v1/replay/bundle" + qs
114
+
115
+ req = urllib.request.Request(
116
+ url,
117
+ headers={
118
+ "Authorization": f"Bearer {api_key}",
119
+ "User-Agent": _SDK_USER_AGENT,
120
+ },
121
+ method="GET",
122
+ )
123
+
124
+ try:
125
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
126
+ raw = resp.read()
127
+ except urllib.error.HTTPError as exc:
128
+ body = exc.read().decode("utf-8", errors="replace")
129
+ if exc.code == 404:
130
+ raise RuntimeError(
131
+ f"HTTP 404 from {url}\n"
132
+ f"The StubSmith backend was not found at that URL.\n"
133
+ f"For the hosted service the URL is https://app.stubsmith.dev/api.\n"
134
+ f"Override with STUBSMITH_API_URL, or unset it to use the default."
135
+ ) from exc
136
+ raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
137
+ except urllib.error.URLError as exc:
138
+ raise RuntimeError(
139
+ f"Network error reaching {url}: {exc.reason}\n"
140
+ f"Check that the backend is reachable, or set STUBSMITH_API_URL "
141
+ f"to override (default: https://app.stubsmith.dev/api)."
142
+ ) from exc
143
+
144
+ try:
145
+ return json.loads(raw.decode("utf-8"))
146
+ except json.JSONDecodeError as exc:
147
+ raise ValueError(f"Server returned unparseable JSON: {exc}") from exc
148
+
149
+
150
+ def _sort_bundle(data: Dict[str, Any]) -> Dict[str, Any]:
151
+ """Return a copy of *data* with endpoints/stubs/variants sorted.
152
+
153
+ Sorting is deterministic so that two pulls with the same server state
154
+ produce byte-for-byte identical files.
155
+
156
+ - Endpoints: by ``(domain, method, path_template)``
157
+ - Stubs: by ``fingerprint``
158
+ - Variants: by ``status``
159
+
160
+ Body strings are passed through as received; they are not re-encoded.
161
+ """
162
+ endpoints: List[Dict[str, Any]] = data.get("endpoints") or []
163
+
164
+ sorted_endpoints = []
165
+ for ep in sorted(
166
+ endpoints,
167
+ key=lambda e: (e.get("domain") or "", e.get("method") or "", e.get("path_template") or ""),
168
+ ):
169
+ stubs: List[Dict[str, Any]] = ep.get("stubs") or []
170
+ sorted_stubs = []
171
+ for stub in sorted(stubs, key=lambda s: s.get("fingerprint") or ""):
172
+ variants: List[Dict[str, Any]] = stub.get("variants") or []
173
+ sorted_variants = sorted(variants, key=lambda v: v.get("status") or 0)
174
+ sorted_stubs.append({**stub, "variants": sorted_variants})
175
+ sorted_endpoints.append({**ep, "stubs": sorted_stubs})
176
+
177
+ return {**data, "endpoints": sorted_endpoints}
178
+
179
+
180
+ def _count_stubs(endpoints: List[Dict[str, Any]]) -> int:
181
+ return sum(len(ep.get("stubs") or []) for ep in endpoints)
182
+
183
+
184
+ def _count_variants(endpoints: List[Dict[str, Any]]) -> int:
185
+ return sum(
186
+ len(stub.get("variants") or [])
187
+ for ep in endpoints
188
+ for stub in (ep.get("stubs") or [])
189
+ )
190
+
191
+
192
+ def _count_degraded(endpoints: List[Dict[str, Any]]) -> int:
193
+ return sum(
194
+ 1
195
+ for ep in endpoints
196
+ for stub in (ep.get("stubs") or [])
197
+ if stub.get("degraded")
198
+ )
199
+
200
+
201
+ def _count_body_capped(endpoints: List[Dict[str, Any]]) -> int:
202
+ return sum(
203
+ 1
204
+ for ep in endpoints
205
+ for stub in (ep.get("stubs") or [])
206
+ for variant in (stub.get("variants") or [])
207
+ if variant.get("body_capped")
208
+ )
209
+
210
+
211
+ def _write_bundle(out_path: str, data: Dict[str, Any]) -> None:
212
+ """Write *data* to *out_path* atomically.
213
+
214
+ The file is written to a temporary path in the same directory first, then
215
+ renamed into place. This means a concurrent reader never sees a partial
216
+ file, and a failed write leaves the previous bundle intact.
217
+
218
+ Parameters
219
+ ----------
220
+ out_path:
221
+ Destination file path. Parent directories are created if absent.
222
+ data:
223
+ Bundle data to serialise.
224
+ """
225
+ dest = pathlib.Path(out_path)
226
+ dest.parent.mkdir(parents=True, exist_ok=True)
227
+
228
+ # json.dumps with sort_keys=True guarantees key-sorted output regardless
229
+ # of insertion order in any nested dict.
230
+ serialised = json.dumps(data, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
231
+
232
+ # Write atomically: temp file in the same directory so the rename is local.
233
+ fd, tmp = tempfile.mkstemp(dir=str(dest.parent), prefix=".stubsmith-bundle-")
234
+ try:
235
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
236
+ fh.write(serialised)
237
+ # mkstemp creates 0600; restore conventional 0644 before renaming so
238
+ # that each pull does not silently reset permissions on a committed file.
239
+ os.chmod(tmp, 0o644)
240
+ os.replace(tmp, str(dest))
241
+ except Exception:
242
+ try:
243
+ os.unlink(tmp)
244
+ except OSError:
245
+ pass
246
+ raise
247
+
248
+
249
+ def _print_summary(data: Dict[str, Any], out_path: str) -> None:
250
+ """Print a human-readable summary of the bundle to stdout."""
251
+ endpoints = data.get("endpoints") or []
252
+ n_ep = len(endpoints)
253
+ n_stubs = _count_stubs(endpoints)
254
+ n_variants = _count_variants(endpoints)
255
+ cursor = data.get("cursor", "")
256
+ gen_at = data.get("generated_at", "")
257
+
258
+ print(f"Wrote {out_path}")
259
+ print(f" endpoints : {n_ep}")
260
+ print(f" stubs : {n_stubs}")
261
+ print(f" variants : {n_variants}")
262
+ print(f" cursor : {cursor}")
263
+ print(f" generated : {gen_at}")
264
+
265
+ # Surface caps and degraded stubs prominently - a bundle that looks complete
266
+ # but contains unusable stubs is the worst outcome.
267
+ truncated = data.get("truncated")
268
+ if truncated:
269
+ print()
270
+ print("WARNING: bundle is truncated - not all data was included.")
271
+ stubs_trunc = truncated.get("stubs")
272
+ if stubs_trunc:
273
+ print(
274
+ f" stubs: {stubs_trunc.get('dropped')} stubs dropped "
275
+ f"(server limit: {stubs_trunc.get('limit')})"
276
+ )
277
+ variants_trunc = truncated.get("variants")
278
+ if variants_trunc:
279
+ total_dropped = sum(v.get("dropped", 0) for v in variants_trunc)
280
+ print(
281
+ f" variants: {total_dropped} variant(s) dropped across "
282
+ f"{len(variants_trunc)} fingerprint(s) "
283
+ f"(server limit per fingerprint: {variants_trunc[0].get('limit')})"
284
+ )
285
+ body_bytes_trunc = truncated.get("body_bytes")
286
+ if body_bytes_trunc:
287
+ limit_kb = body_bytes_trunc.get("limit", 0) // 1024
288
+ print(
289
+ f" body_bytes: {body_bytes_trunc.get('capped')} variant body/bodies "
290
+ f"omitted (exceeded {limit_kb} KB cap) - these stubs will replay with "
291
+ f"an empty body"
292
+ )
293
+
294
+ n_degraded = _count_degraded(endpoints)
295
+ n_body_capped = _count_body_capped(endpoints)
296
+
297
+ if n_degraded:
298
+ print(
299
+ f"WARNING: {n_degraded} stub(s) marked degraded - "
300
+ "no recorded captures are available; replay will fail for those stubs."
301
+ )
302
+ if n_body_capped:
303
+ print(
304
+ f"WARNING: {n_body_capped} variant(s) have body_capped=true - "
305
+ "body exceeded the server size cap and was omitted; "
306
+ "replay will return an empty body for those variants."
307
+ )
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Main entry point
312
+ # ---------------------------------------------------------------------------
313
+
314
+ def _cmd_pull(args: argparse.Namespace) -> int:
315
+ """Implement the ``pull`` subcommand.
316
+
317
+ Parameters
318
+ ----------
319
+ args:
320
+ Parsed namespace from the ``pull`` subparser.
321
+
322
+ Returns
323
+ -------
324
+ int
325
+ Exit code: 0 on success, non-zero on any failure.
326
+ """
327
+ # ── Resolve credentials ───────────────────────────────────────────────
328
+ api_key = os.environ.get("STUBSMITH_API_KEY", "")
329
+ if not api_key:
330
+ print(
331
+ "Error: STUBSMITH_API_KEY is not set. "
332
+ "Export your project API key before running stubsmith pull.",
333
+ file=sys.stderr,
334
+ )
335
+ return 1
336
+
337
+ api_url = _resolve_api_url()
338
+
339
+ # ── Parse --endpoint filter ───────────────────────────────────────────
340
+ ep_method: Optional[str] = None
341
+ ep_path: Optional[str] = None
342
+ if args.endpoint:
343
+ parts = args.endpoint.split(" ", 1)
344
+ if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
345
+ print(
346
+ f"Error: --endpoint must be \"METHOD /path\" (e.g. \"GET /api/users\"), "
347
+ f"got: {args.endpoint!r}",
348
+ file=sys.stderr,
349
+ )
350
+ return 1
351
+ ep_method = parts[0].strip().upper()
352
+ ep_path = parts[1].strip()
353
+
354
+ # ── Fetch ─────────────────────────────────────────────────────────────
355
+ try:
356
+ raw_data = _fetch_bundle(api_url, api_key, method=ep_method, path=ep_path)
357
+ except RuntimeError as exc:
358
+ print(f"Error: {exc}", file=sys.stderr)
359
+ return 1
360
+ except ValueError as exc:
361
+ print(f"Error: {exc}", file=sys.stderr)
362
+ return 1
363
+
364
+ # ── Sort for determinism, then write ─────────────────────────────────
365
+ try:
366
+ data = _sort_bundle(raw_data)
367
+ _write_bundle(args.out, data)
368
+ except Exception as exc:
369
+ print(f"Error writing bundle: {exc}", file=sys.stderr)
370
+ return 1
371
+
372
+ # ── Summary ───────────────────────────────────────────────────────────
373
+ _print_summary(data, args.out)
374
+ return 0
375
+
376
+
377
+ def main(argv: Optional[List[str]] = None) -> int:
378
+ """CLI entry point for the ``stubsmith`` command.
379
+
380
+ Parameters
381
+ ----------
382
+ argv:
383
+ Argument list (excluding the program name), e.g. ``["pull"]`` or
384
+ ``["pull", "--out", "my/bundle.json"]``. Defaults to
385
+ ``sys.argv[1:]`` when ``None``.
386
+
387
+ Returns
388
+ -------
389
+ int
390
+ Exit code: 0 on success, non-zero on any failure.
391
+ """
392
+ parser = argparse.ArgumentParser(
393
+ prog="stubsmith",
394
+ description="StubSmith command-line tools.",
395
+ )
396
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
397
+ # required=True makes argparse emit "the following arguments are required:
398
+ # <command>" and exit 2 when no subcommand is given, which is the correct
399
+ # behaviour for a CLI whose no-argument form should not silently take an action.
400
+ subparsers.required = True
401
+
402
+ # ── pull subcommand ───────────────────────────────────────────────────
403
+ pull_parser = subparsers.add_parser(
404
+ "pull",
405
+ help="Fetch the replay bundle and write it to disk.",
406
+ description=(
407
+ "Fetch the StubSmith replay bundle and write it to disk so tests "
408
+ "can run offline without an API key."
409
+ ),
410
+ )
411
+ pull_parser.add_argument(
412
+ "--out",
413
+ default=_DEFAULT_OUT,
414
+ metavar="PATH",
415
+ help=f"Destination file (default: {_DEFAULT_OUT})",
416
+ )
417
+ pull_parser.add_argument(
418
+ "--endpoint",
419
+ default=None,
420
+ metavar="\"METHOD /path/template\"",
421
+ help=(
422
+ 'Filter to a single endpoint, e.g. "GET /api/users/{id}". '
423
+ "Passes method= and path= query parameters to the server."
424
+ ),
425
+ )
426
+
427
+ args = parser.parse_args(argv)
428
+
429
+ if args.command == "pull":
430
+ return _cmd_pull(args)
431
+
432
+ # Unreachable when subparsers.required=True, but kept for forward safety.
433
+ parser.print_help(sys.stderr)
434
+ return 2
435
+
436
+
437
+ def _cli_entry() -> None:
438
+ """Console script shim: calls ``main()`` and exits with its return code."""
439
+ sys.exit(main())