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 +87 -0
- stubsmith/__main__.py +15 -0
- stubsmith/_replay_state.py +43 -0
- stubsmith/_version.py +4 -0
- stubsmith/cli.py +439 -0
- stubsmith/client.py +784 -0
- stubsmith/fixtures.py +365 -0
- stubsmith/instrument.py +76 -0
- stubsmith/privacy/__init__.py +98 -0
- stubsmith/privacy/binary.py +101 -0
- stubsmith/privacy/field_rules.py +686 -0
- stubsmith/privacy/fingerprint.py +514 -0
- stubsmith/privacy/masking.py +330 -0
- stubsmith/privacy/pipeline.py +361 -0
- stubsmith/privacy/placeholders.py +398 -0
- stubsmith/privacy/rules_cache.py +514 -0
- stubsmith/privacy/templating.py +130 -0
- stubsmith/replay.py +1077 -0
- stubsmith/testing.py +544 -0
- stubsmith-0.1.0.dist-info/METADATA +318 -0
- stubsmith-0.1.0.dist-info/RECORD +25 -0
- stubsmith-0.1.0.dist-info/WHEEL +5 -0
- stubsmith-0.1.0.dist-info/entry_points.txt +2 -0
- stubsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
- stubsmith-0.1.0.dist-info/top_level.txt +1 -0
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
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())
|