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 +23 -0
- stubfetch/eval_runner.py +32 -0
- stubfetch/export.py +31 -0
- stubfetch/ghost_env.py +149 -0
- stubfetch/presets.py +261 -0
- stubfetch/recorder.py +38 -0
- stubfetch/replay.py +29 -0
- stubfetch/seed.py +10 -0
- stubfetch/world.py +24 -0
- stubfetch-0.1.0.dist-info/METADATA +114 -0
- stubfetch-0.1.0.dist-info/RECORD +12 -0
- stubfetch-0.1.0.dist-info/WHEEL +4 -0
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
|
+
]
|
stubfetch/eval_runner.py
ADDED
|
@@ -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,,
|