stubfetch 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.
stubfetch/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from stubfetch.ghost_env import GhostEnv, GhostEnvConfig, Provider
2
+ from stubfetch.export import export_recording_json, export_recording_markdown, export_har
3
+ from stubfetch.eval_runner import run_eval, define_scenario
4
+ from stubfetch.replay import ReplayFixture
5
+ from stubfetch.presets import anthropic, github, postgres, s3, slack, stripe
6
+
7
+ __all__ = [
8
+ "GhostEnv",
9
+ "GhostEnvConfig",
10
+ "Provider",
11
+ "github",
12
+ "stripe",
13
+ "postgres",
14
+ "s3",
15
+ "slack",
16
+ "anthropic",
17
+ "export_recording_json",
18
+ "export_recording_markdown",
19
+ "export_har",
20
+ "run_eval",
21
+ "define_scenario",
22
+ "ReplayFixture",
23
+ ]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, TypedDict
4
+
5
+ from stubfetch.ghost_env import GhostEnv, GhostEnvConfig
6
+
7
+
8
+ class Scenario(TypedDict, total=False):
9
+ name: str
10
+ config: GhostEnvConfig
11
+ run: Callable[[GhostEnv], None]
12
+ assert_: Callable[[GhostEnv], None]
13
+
14
+
15
+ def define_scenario(**kwargs: Any) -> dict[str, Any]:
16
+ return kwargs
17
+
18
+
19
+ def run_eval(scenarios: list[dict[str, Any]]) -> dict[str, Any]:
20
+ results: list[dict[str, Any]] = []
21
+ for s in scenarios:
22
+ env = GhostEnv(s["config"])
23
+ try:
24
+ s["run"](env)
25
+ check = s.get("check")
26
+ if check:
27
+ check(env)
28
+ results.append({"name": s["name"], "ok": True})
29
+ except Exception as e: # noqa: BLE001
30
+ results.append({"name": s["name"], "ok": False, "error": str(e)})
31
+ ok = sum(1 for r in results if r["ok"])
32
+ return {"results": results, "pass_rate": ok / max(len(results), 1)}
stubfetch/export.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from stubfetch.recorder import CallRecord
7
+
8
+
9
+ def export_recording_json(calls: list[CallRecord]) -> str:
10
+ return json.dumps([c.__dict__ for c in calls], indent=2)
11
+
12
+
13
+ def export_recording_markdown(calls: list[CallRecord]) -> str:
14
+ lines = ["# stubfetch recording", ""]
15
+ for c in calls:
16
+ lines.append(f"## {c.provider} {c.method} {c.url}")
17
+ lines.append(f"- status: {c.response_status}")
18
+ lines.append("")
19
+ return "\n".join(lines)
20
+
21
+
22
+ def export_har(calls: list[CallRecord], title: str = "stubfetch") -> str:
23
+ entries = []
24
+ for c in calls:
25
+ entries.append(
26
+ {
27
+ "request": {"method": c.method, "url": c.url},
28
+ "response": {"status": c.response_status, "content": {"text": str(c.response_body)}},
29
+ }
30
+ )
31
+ return json.dumps({"log": {"version": "1.2", "creator": {"name": title}, "entries": entries}}, indent=2)
stubfetch/ghost_env.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from typing import Any, Callable, TypedDict
7
+
8
+ from stubfetch.recorder import Recorder
9
+ from stubfetch.seed import mulberry32
10
+ from stubfetch.world import WorldState
11
+
12
+
13
+ class Provider(TypedDict, total=False):
14
+ name: str
15
+ seed: Callable[[WorldState, Callable[[], float]], None]
16
+ handle: Callable[[str, str, Any, WorldState], tuple[int, str] | None]
17
+
18
+
19
+ class GhostEnvConfig(TypedDict, total=False):
20
+ seed: int
21
+ providers: list[Provider]
22
+ chaos: dict[str, Any]
23
+
24
+
25
+ class GhostEnv:
26
+ def __init__(self, config: GhostEnvConfig) -> None:
27
+ self.world = WorldState()
28
+ self._recorder = Recorder()
29
+ self._providers = config["providers"]
30
+ self._chaos = config.get("chaos") or {}
31
+ self._rng = mulberry32(int(config.get("seed", 1)))
32
+ self._snap: str | None = None
33
+ for p in self._providers:
34
+ if p.get("seed"):
35
+ p["seed"](self.world, self._rng)
36
+
37
+ def reset(self) -> None:
38
+ if self._snap is None:
39
+ self._recorder.clear()
40
+ self.world.restore("{}")
41
+ for p in self._providers:
42
+ if p.get("seed"):
43
+ p["seed"](self.world, self._rng)
44
+ return
45
+ self.world.restore(self._snap)
46
+ self._recorder.clear()
47
+
48
+ def snapshot(self) -> str:
49
+ self._snap = self.world.snapshot()
50
+ return self._snap
51
+
52
+ def restore(self, data: str) -> None:
53
+ self.world.restore(data)
54
+
55
+ def fetch(self, url: str, method: str = "GET", body: str | None = None) -> tuple[int, str]:
56
+ parsed_body: Any = None
57
+ if body:
58
+ try:
59
+ parsed_body = json.loads(body)
60
+ except json.JSONDecodeError:
61
+ parsed_body = body
62
+ start = time.perf_counter()
63
+ matched = "unknown"
64
+
65
+ lat = float(self._chaos.get("min_latency_ms", 0) or 0)
66
+ if lat:
67
+ time.sleep(lat / 1000.0)
68
+ fr = float(self._chaos.get("failure_rate", 0) or 0)
69
+ if fr and self._rng() < fr:
70
+ self._recorder.record(
71
+ provider="error",
72
+ method=method,
73
+ url=url,
74
+ request_body=parsed_body,
75
+ response_status=500,
76
+ response_body="chaos",
77
+ duration_ms=(time.perf_counter() - start) * 1000,
78
+ )
79
+ raise RuntimeError("stubfetch chaos")
80
+
81
+ status = 500
82
+ text = ""
83
+ try:
84
+ for p in self._providers:
85
+ h = p.get("handle")
86
+ if not h:
87
+ continue
88
+ res = h(url, method.upper(), parsed_body, self.world)
89
+ if res:
90
+ matched = p["name"]
91
+ status, text = res
92
+ break
93
+ else:
94
+ raise RuntimeError(f"stubfetch: no provider matched {method} {url}")
95
+ except Exception as e: # noqa: BLE001
96
+ self._recorder.record(
97
+ provider="error",
98
+ method=method,
99
+ url=url,
100
+ request_body=parsed_body,
101
+ response_status=500,
102
+ response_body=str(e),
103
+ duration_ms=(time.perf_counter() - start) * 1000,
104
+ )
105
+ raise
106
+
107
+ duration = (time.perf_counter() - start) * 1000
108
+ try:
109
+ parsed_resp = json.loads(text)
110
+ except json.JSONDecodeError:
111
+ parsed_resp = text
112
+ self._recorder.record(
113
+ provider=matched,
114
+ method=method,
115
+ url=url,
116
+ request_body=parsed_body,
117
+ response_status=status,
118
+ response_body=parsed_resp,
119
+ duration_ms=duration,
120
+ )
121
+ return status, text
122
+
123
+ def calls(self, provider: str | None = None):
124
+ return self._recorder.filter(provider)
125
+
126
+ def was_called(self, provider: str, **kwargs: Any) -> bool:
127
+ method = kwargs.get("method")
128
+ path_includes = kwargs.get("path_includes")
129
+ for c in self.calls(provider):
130
+ if method and c.method != method:
131
+ continue
132
+ if path_includes and path_includes not in c.url:
133
+ continue
134
+ return True
135
+ return False
136
+
137
+ def db(self, _name: str = "postgres"):
138
+ world = self.world
139
+
140
+ class Db:
141
+ @staticmethod
142
+ def query(sql: str, params: list[Any] | None = None):
143
+ params = params or []
144
+ m = re.search(r"FROM\s+(\w+)", sql, re.I)
145
+ table = (m.group(1).lower() if m else "unknown")
146
+ rows = world.list(f"pg:{table}")
147
+ return {"rows": rows, "row_count": len(rows)}
148
+
149
+ return Db()
stubfetch/presets.py ADDED
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Callable
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ from stubfetch.world import WorldState
9
+
10
+
11
+ def _next_github_id(world: WorldState) -> int:
12
+ xs = world.list("github_issue")
13
+ return max((int(x.get("id", 0)) for x in xs), default=0) + 1
14
+
15
+
16
+ def github(config: dict[str, Any]) -> dict[str, Any]:
17
+ issues = config.get("issues") or []
18
+
19
+ def seed(world: WorldState, rng: Callable[[], float]) -> None:
20
+ for i, it in enumerate(issues, start=1):
21
+ rid = _next_github_id(world)
22
+ world.set(
23
+ "github_issue",
24
+ f"{it['repo']}#{i}",
25
+ {
26
+ "id": rid,
27
+ "number": i,
28
+ "repo": it["repo"],
29
+ "title": it["title"],
30
+ "body": it.get("body", ""),
31
+ "state": it.get("state", "open"),
32
+ "labels": it.get("labels", []),
33
+ },
34
+ )
35
+ rng()
36
+
37
+ def handle(url: str, method: str, body: Any, world: WorldState):
38
+ p = urlparse(url).path
39
+ m = re.match(r"^/repos/([^/]+)/([^/]+)/issues/?$", p)
40
+ if m and method == "GET":
41
+ prefix = f"{m.group(1)}/{m.group(2)}"
42
+ data = [x for x in world.list("github_issue") if x.get("repo") == prefix]
43
+ return (200, json.dumps(data))
44
+ if m and method == "POST":
45
+ repo = f"{m.group(1)}/{m.group(2)}"
46
+ payload = json.loads(body) if isinstance(body, str) else (body or {})
47
+ num = len([x for x in world.list("github_issue") if x.get("repo") == repo]) + 1
48
+ rid = _next_github_id(world)
49
+ issue = {
50
+ "id": rid,
51
+ "number": num,
52
+ "title": payload.get("title", "untitled"),
53
+ "body": payload.get("body", ""),
54
+ "state": "open",
55
+ "labels": [],
56
+ "repo": repo,
57
+ }
58
+ world.set("github_issue", f"{repo}#{num}", issue)
59
+ public = {k: v for k, v in issue.items() if k != "repo"}
60
+ return (201, json.dumps(public))
61
+ return None
62
+
63
+ return {"name": "github", "seed": seed, "handle": handle}
64
+
65
+
66
+ def stripe(config: dict[str, Any]) -> dict[str, Any]:
67
+ customers = config.get("customers") or []
68
+
69
+ def seed(world: WorldState, rng: Callable[[], float]) -> None:
70
+ for i, c in enumerate(customers, start=1):
71
+ cid = c.get("id") or f"cus_{i}"
72
+ world.set("stripe_customer", cid, {"id": cid, "email": c["email"], "object": "customer"})
73
+ rng()
74
+
75
+ def handle(url: str, method: str, _body: Any, world: WorldState):
76
+ p = urlparse(url).path
77
+ if method == "GET" and p.rstrip("/") == "/v1/customers":
78
+ data = world.list("stripe_customer")
79
+ return (200, json.dumps({"object": "list", "data": data, "has_more": False}))
80
+ return None
81
+
82
+ return {"name": "stripe", "seed": seed, "handle": handle}
83
+
84
+
85
+ def postgres(config: dict[str, Any]) -> dict[str, Any]:
86
+ tables = config.get("tables") or {}
87
+
88
+ def seed(world: WorldState, rng: Callable[[], float]) -> None:
89
+ for table, rows in tables.items():
90
+ for i, row in enumerate(rows):
91
+ rid = str(row.get("id", i))
92
+ world.set(f"pg:{table}", rid, dict(row))
93
+ rng()
94
+
95
+ return {"name": "postgres", "seed": seed, "handle": None}
96
+
97
+
98
+ def s3(config: dict[str, Any]) -> dict[str, Any]:
99
+ objects = config.get("objects") or []
100
+
101
+ def norm_key(k: str) -> str:
102
+ return k.lstrip("/")
103
+
104
+ def seed(world: WorldState, rng: Callable[[], float]) -> None:
105
+ for o in objects:
106
+ b = str(o["bucket"])
107
+ k = norm_key(str(o["key"]))
108
+ world.set(
109
+ "s3:object",
110
+ f"{b}/{k}",
111
+ {
112
+ "bucket": b,
113
+ "key": k,
114
+ "body": str(o.get("body", "")),
115
+ "etag": str(o.get("etag", f'"{b}-{k}"')),
116
+ },
117
+ )
118
+ rng()
119
+
120
+ def handle(url: str, method: str, _body: Any, world: WorldState) -> tuple[int, str] | None:
121
+ if method != "GET":
122
+ return None
123
+ u = urlparse(url)
124
+ path = u.path.rstrip("/") or "/"
125
+ h = u.hostname or ""
126
+
127
+ vh = re.match(r"^([^.]+)\.s3[.-][^/]+\.amazonaws\.com$", h, re.I)
128
+ if vh:
129
+ bucket = vh.group(1)
130
+ key = norm_key(path[1:] if path.startswith("/") else path)
131
+ if not key:
132
+ return None
133
+ rec = world.get("s3:object", f"{bucket}/{key}")
134
+ if not rec:
135
+ return (404, "Not Found")
136
+ return (200, str(rec.get("body", "")))
137
+
138
+ path_style = "amazonaws.com" in h and (h == "s3.amazonaws.com" or h.startswith("s3."))
139
+ if path_style:
140
+ parts = [p for p in path.split("/") if p]
141
+ if not parts:
142
+ return None
143
+ bucket = parts[0]
144
+ qs = parse_qs(u.query)
145
+ lt = (qs.get("list-type") or [None])[0]
146
+ if len(parts) == 1 and lt == "2":
147
+ prefix = (qs.get("prefix") or [""])[0]
148
+ contents: list[dict[str, Any]] = []
149
+ for o in world.list("s3:object"):
150
+ if o.get("bucket") != bucket:
151
+ continue
152
+ kk = str(o.get("key", ""))
153
+ if not kk.startswith(prefix):
154
+ continue
155
+ contents.append(
156
+ {
157
+ "Key": kk,
158
+ "Size": len(str(o.get("body", "")).encode("utf-8")),
159
+ "ETag": o.get("etag"),
160
+ }
161
+ )
162
+ payload = {
163
+ "Name": bucket,
164
+ "KeyCount": len(contents),
165
+ "IsTruncated": False,
166
+ "Contents": contents,
167
+ }
168
+ return (200, json.dumps(payload))
169
+ if len(parts) < 2:
170
+ return None
171
+ key = norm_key("/".join(parts[1:]))
172
+ rec = world.get("s3:object", f"{bucket}/{key}")
173
+ if not rec:
174
+ return (404, "Not Found")
175
+ return (200, str(rec.get("body", "")))
176
+ return None
177
+
178
+ return {"name": "s3", "seed": seed, "handle": handle}
179
+
180
+
181
+ def slack(config: dict[str, Any] | None = None) -> dict[str, Any]:
182
+ cfg = config or {}
183
+ bot = str(cfg.get("bot_user_id", "UFAKEBOT"))
184
+
185
+ def seed(_world: WorldState, rng: Callable[[], float]) -> None:
186
+ rng()
187
+
188
+ def handle(url: str, method: str, body: Any, _world: WorldState) -> tuple[int, str] | None:
189
+ u = urlparse(url)
190
+ h = u.hostname or ""
191
+ if not h.endswith("slack.com"):
192
+ return None
193
+ p = u.path.rstrip("/") or "/"
194
+ if p == "/api/auth.test" and method in ("GET", "POST"):
195
+ return (
196
+ 200,
197
+ json.dumps(
198
+ {
199
+ "ok": True,
200
+ "url": "https://acme.slack.com/",
201
+ "team": "acme",
202
+ "user": bot,
203
+ "team_id": "T0001",
204
+ "user_id": bot,
205
+ }
206
+ ),
207
+ )
208
+ if p == "/api/chat.postMessage" and method == "POST":
209
+ text = ""
210
+ try:
211
+ payload = json.loads(body) if isinstance(body, str) else (body or {})
212
+ text = f'{payload.get("channel", "")}:{payload.get("text", "")}'
213
+ except (json.JSONDecodeError, TypeError):
214
+ text = ""
215
+ ts = "1234567890.000100"
216
+ return (
217
+ 200,
218
+ json.dumps(
219
+ {
220
+ "ok": True,
221
+ "channel": "C0001",
222
+ "ts": ts,
223
+ "message": {"user": bot, "type": "message", "text": text},
224
+ }
225
+ ),
226
+ )
227
+ return None
228
+
229
+ return {"name": "slack", "seed": seed, "handle": handle}
230
+
231
+
232
+ def anthropic(config: dict[str, Any] | None = None) -> dict[str, Any]:
233
+ cfg = config or {}
234
+ texts = list(cfg.get("texts") or ["Hello from fake Claude"])
235
+ state = {"i": 0}
236
+
237
+ def seed(_world: WorldState, rng: Callable[[], float]) -> None:
238
+ state["i"] = 0
239
+ rng()
240
+
241
+ def handle(url: str, method: str, _body: Any, _world: WorldState) -> tuple[int, str] | None:
242
+ u = urlparse(url)
243
+ if "api.anthropic.com" not in (u.hostname or ""):
244
+ return None
245
+ if method != "POST" or "/v1/messages" not in u.path:
246
+ return None
247
+ i = state["i"]
248
+ state["i"] = i + 1
249
+ text = texts[i % len(texts)]
250
+ payload = {
251
+ "id": f"msg_{i}",
252
+ "type": "message",
253
+ "role": "assistant",
254
+ "model": str(cfg.get("model", "claude-3-5-sonnet-20241022")),
255
+ "content": [{"type": "text", "text": text}],
256
+ "stop_reason": "end_turn",
257
+ "usage": {"input_tokens": 1, "output_tokens": 1},
258
+ }
259
+ return (200, json.dumps(payload))
260
+
261
+ return {"name": "anthropic", "seed": seed, "handle": handle}
stubfetch/recorder.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class CallRecord:
9
+ id: str
10
+ provider: str
11
+ method: str
12
+ url: str
13
+ request_body: Any
14
+ response_status: int
15
+ response_body: Any
16
+ duration_ms: float
17
+
18
+
19
+ class Recorder:
20
+ def __init__(self) -> None:
21
+ self._calls: list[CallRecord] = []
22
+ self._seq = 0
23
+
24
+ def record(self, **kwargs: Any) -> None:
25
+ self._seq += 1
26
+ self._calls.append(CallRecord(id=f"c{self._seq}", **kwargs))
27
+
28
+ def all(self) -> list[CallRecord]:
29
+ return list(self._calls)
30
+
31
+ def filter(self, provider: str | None = None) -> list[CallRecord]:
32
+ if not provider:
33
+ return self.all()
34
+ return [c for c in self._calls if c.provider == provider]
35
+
36
+ def clear(self) -> None:
37
+ self._calls.clear()
38
+ self._seq = 0
stubfetch/replay.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from stubfetch.recorder import CallRecord
7
+
8
+
9
+ class ReplayFixture:
10
+ def __init__(self, calls: list[dict[str, Any]]) -> None:
11
+ self._calls = calls
12
+ self._idx = 0
13
+
14
+ @classmethod
15
+ def from_json(cls, raw: str) -> ReplayFixture:
16
+ data = json.loads(raw)
17
+ return cls(data)
18
+
19
+ def next_response(self) -> tuple[int, str] | None:
20
+ if self._idx >= len(self._calls):
21
+ return None
22
+ c = self._calls[self._idx]
23
+ self._idx += 1
24
+ body = c.get("response_body")
25
+ text = body if isinstance(body, str) else json.dumps(body)
26
+ return int(c.get("response_status", 200)), text
27
+
28
+ def reset(self) -> None:
29
+ self._idx = 0
stubfetch/seed.py ADDED
@@ -0,0 +1,10 @@
1
+ def mulberry32(seed: int):
2
+ def _rng() -> float:
3
+ nonlocal seed
4
+ seed = (seed + 0x6D2B79F5) & 0xFFFFFFFF
5
+ t = seed
6
+ t = (t ^ (t >> 15)) * (t | 1) & 0xFFFFFFFF
7
+ t ^= t + ((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF
8
+ return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
9
+
10
+ return _rng
stubfetch/world.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ class WorldState:
8
+ def __init__(self) -> None:
9
+ self._entities: dict[str, dict[str, dict[str, Any]]] = {}
10
+
11
+ def list(self, typ: str) -> list[dict[str, Any]]:
12
+ return list(self._entities.get(typ, {}).values())
13
+
14
+ def get(self, typ: str, id_: str) -> dict[str, Any] | None:
15
+ return self._entities.get(typ, {}).get(id_)
16
+
17
+ def set(self, typ: str, id_: str, value: dict[str, Any]) -> None:
18
+ self._entities.setdefault(typ, {})[id_] = value
19
+
20
+ def snapshot(self) -> str:
21
+ return json.dumps(self._entities)
22
+
23
+ def restore(self, data: str) -> None:
24
+ self._entities = json.loads(data) if data else {}
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: stubfetch
3
+ Version: 0.1.0
4
+ Summary: Deterministic fake APIs for AI agent tests
5
+ Project-URL: Homepage, https://slaps.dev/stubfetch
6
+ Project-URL: Documentation, https://slaps.dev/docs/stubfetch/
7
+ Project-URL: Repository, https://github.com/vgulerianb/stubfetch
8
+ Project-URL: Bug Tracker, https://github.com/vgulerianb/stubfetch/issues
9
+ Author: slaps.dev
10
+ License-Expression: Apache-2.0
11
+ Keywords: agents,ai,anthropic,deterministic,e2e,fetch,github,http,integration-testing,mock,openai,stripe,testing
12
+ Requires-Python: >=3.10
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # stubfetch
18
+
19
+ **Deterministic, in-process fake HTTP APIs** for testing agents and tools. Swap real network calls for canned **GitHub**, **Stripe**, **Postgres-shaped** data, **OpenAI** / **Anthropic**-style LLM JSON, **S3**-like objects, **Slack** Web API responses—while **recording** traffic, exporting **JSON / Markdown / HAR**, running **eval scenarios**, and optionally injecting **chaos** (latency, random failures).
20
+
21
+ Same ideas in **TypeScript** (npm) and **Python** (PyPI / local install).
22
+
23
+ ---
24
+
25
+ ## Install
26
+
27
+ ### JavaScript / TypeScript (npm)
28
+
29
+ ```bash
30
+ npm install stubfetch
31
+ ```
32
+
33
+ Requires **Node.js ≥ 18**. No runtime npm dependencies.
34
+
35
+ ### Python
36
+
37
+ ```bash
38
+ pip install stubfetch
39
+ ```
40
+
41
+ From a clone (editable):
42
+
43
+ ```bash
44
+ pip install -e ./python
45
+ ```
46
+
47
+ Requires **Python ≥ 3.10**.
48
+
49
+ ---
50
+
51
+ ## Quick start
52
+
53
+ ### TypeScript
54
+
55
+ ```ts
56
+ import { GhostEnv, github, exportRecordingJSON } from "stubfetch";
57
+
58
+ const env = new GhostEnv({
59
+ seed: 42,
60
+ providers: [github({ issues: [{ repo: "acme/api", title: "Bug" }] })],
61
+ });
62
+
63
+ const res = await env.fetch("https://api.github.com/repos/acme/api/issues");
64
+ console.log(await res.json());
65
+ console.log(exportRecordingJSON(env.calls()));
66
+ ```
67
+
68
+ ### Python
69
+
70
+ ```python
71
+ from stubfetch import GhostEnv, github, export_recording_json
72
+
73
+ env = GhostEnv(
74
+ {"seed": 1, "providers": [github({"issues": [{"repo": "acme/api", "title": "Bug"}]})]}
75
+ )
76
+ status, text = env.fetch("https://api.github.com/repos/acme/api/issues")
77
+ assert status == 200
78
+ print(export_recording_json(env.calls()))
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Features
84
+
85
+ | Area | What you get |
86
+ |------|----------------|
87
+ | **Presets** | GitHub issues, Stripe customers, Postgres `db().query()`, OpenAI chat, Anthropic messages, S3 GET/list, Slack `auth.test` / `chat.postMessage` |
88
+ | **`GhostEnv.fetch`** | Same shape as `globalThis.fetch`; routes to the first matching provider |
89
+ | **Recording** | `calls()`, `wasCalled()`, `exportRecordingJSON` / `Markdown` / `HAR` |
90
+ | **Eval** | `runEval` + `defineScenario` for scripted agent tests |
91
+ | **Replay** | `ReplayFixture` replays canned responses in order |
92
+ | **Chaos** | `chaos: { minLatencyMs, failureRate }` on config |
93
+
94
+ ---
95
+
96
+ ## Documentation
97
+
98
+ Hosted on **[slaps.dev](https://slaps.dev)**:
99
+
100
+ | Doc | Contents |
101
+ |-----|----------|
102
+ | [Overview](https://slaps.dev/docs/stubfetch/) | Product summary and doc index |
103
+ | [Getting started](https://slaps.dev/docs/stubfetch/getting-started) | Providers, `fetch`, recording |
104
+ | [Use cases](https://slaps.dev/docs/stubfetch/use-cases) | Agents, agentpad, eval patterns |
105
+ | [Presets](https://slaps.dev/docs/stubfetch/presets) | URLs, config shapes, matching rules |
106
+ | [API reference](https://slaps.dev/docs/stubfetch/api-reference) | `GhostEnv`, `Provider`, eval, replay, exports |
107
+ | [Testing & chaos](https://slaps.dev/docs/stubfetch/testing-and-chaos) | `runEval`, chaos options, failure recording |
108
+ | [Python notes](https://slaps.dev/docs/stubfetch/python) | Node vs Python differences |
109
+
110
+ ---
111
+
112
+ ## License
113
+
114
+ Apache-2.0
@@ -0,0 +1,12 @@
1
+ stubfetch/__init__.py,sha256=Vhcg4NppzSz6uAY7HMLu5_X5gtOuY4-ZSNToqyZLccg,630
2
+ stubfetch/eval_runner.py,sha256=hRU2OBj0CuiY9gGWLysnNM_RqQb16_119nPA1DwTNvU,961
3
+ stubfetch/export.py,sha256=AzCq5llsvY1nkCpiXgKiODFVXLNbG7AuBb1Lkfuf8S8,980
4
+ stubfetch/ghost_env.py,sha256=mdo5qz0sWIfPabmU4F2AI6dlQgLNaZEutcnpnagLvG0,4763
5
+ stubfetch/presets.py,sha256=x--4lAxGBS3BBGGQ5ZBMFqNifWmFYX_PKuPd7WDhOdA,9332
6
+ stubfetch/recorder.py,sha256=QaSYUoKt-1bq8amwAFYUKJiu5R5BVWktI1ZUFNdBEp4,875
7
+ stubfetch/replay.py,sha256=97QrQ0Z3HBKn78o982Xcr5n0cNPcSv5CxQhkJjwo-fU,766
8
+ stubfetch/seed.py,sha256=FkmQbSA7uyk_8oA2mQgkdosn8zvT2jGwGmpImsx-edg,326
9
+ stubfetch/world.py,sha256=2idRkPSMcx1yyKp-XTi9_84GM2rHpAhlqS_CqBOCIF0,721
10
+ stubfetch-0.1.0.dist-info/METADATA,sha256=NMykI29E1YVlcQGm3Fgj9cQlHy2yPiREL6p8EXMqP4Y,3611
11
+ stubfetch-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ stubfetch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any