pyhttp-app 2.0.1__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.
- pyhttp/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
pyhttp/scripts.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Optional Python pre-request and test scripts (the Python answer to Postman's JS scripts).
|
|
2
|
+
|
|
3
|
+
Scripts run in-process with a small `py` object (`pm` is an alias for people
|
|
4
|
+
coming from Postman). They are the user's own code on their own machine, so
|
|
5
|
+
there is no sandbox; a script that hangs is abandoned after SCRIPT_TIMEOUT
|
|
6
|
+
seconds and reported as failed.
|
|
7
|
+
|
|
8
|
+
Pre-request script:
|
|
9
|
+
py.variables.set("ts", "123") # this request only
|
|
10
|
+
py.environment.set("token", "abc") # saved in the active environment
|
|
11
|
+
py.globals.set("base", "https://x") # saved globally
|
|
12
|
+
py.variables.get("name") # effective value (globals < folder < env < local)
|
|
13
|
+
py.request.headers["X-Trace"] = py.variables.get("trace") # add / change a header
|
|
14
|
+
py.request.url, py.request.method, py.request.body # editable
|
|
15
|
+
py.request.params["page"] = "2"
|
|
16
|
+
|
|
17
|
+
Test script:
|
|
18
|
+
py.response.status_code, py.response.text, py.response.json(), py.response.headers["content-type"]
|
|
19
|
+
py.response.time_ms, py.response.ok
|
|
20
|
+
token = py.response.json().data.token # dotted access like JavaScript; missing -> None
|
|
21
|
+
py.test("status is 200", py.response.status_code == 200)
|
|
22
|
+
py.test("has token", lambda: py.expect(token).to_be_a(str))
|
|
23
|
+
py.expect(x).to_equal(y) / .to_not_equal(y) / .to_contain(y) / .to_be_true() / .to_be_above(n) / .to_match(regex)
|
|
24
|
+
py.environment.set("token", token)
|
|
25
|
+
|
|
26
|
+
JavaScript habits that are accepted as-is: `const`/`let`/`var`, a trailing `;`,
|
|
27
|
+
`true`/`false`/`null`, and `console.log(...)`. Everything else is Python
|
|
28
|
+
(`and`/`or`/`not`, `==`, `lambda:` instead of `function () {}`).
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import threading
|
|
35
|
+
import traceback
|
|
36
|
+
from typing import Any, Callable
|
|
37
|
+
|
|
38
|
+
SCRIPT_TIMEOUT = 15.0
|
|
39
|
+
|
|
40
|
+
# Lines that look like JavaScript (Postman habit). Used only to make the error message helpful.
|
|
41
|
+
_JS_HINTS = re.compile(r"=>|\bfunction\s*\(|!==|===|\|\||&&|^\s*\}|\{\s*$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Expect:
|
|
45
|
+
def __init__(self, value: Any) -> None:
|
|
46
|
+
self.v = value
|
|
47
|
+
|
|
48
|
+
def _fail(self, msg: str) -> None:
|
|
49
|
+
raise AssertionError(msg)
|
|
50
|
+
|
|
51
|
+
def to_equal(self, other: Any) -> "_Expect":
|
|
52
|
+
if self.v != other:
|
|
53
|
+
self._fail(f"expected {self.v!r} to equal {other!r}")
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def to_not_equal(self, other: Any) -> "_Expect":
|
|
57
|
+
if self.v == other:
|
|
58
|
+
self._fail(f"expected {self.v!r} to differ from {other!r}")
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def to_contain(self, item: Any) -> "_Expect":
|
|
62
|
+
try:
|
|
63
|
+
ok = item in self.v
|
|
64
|
+
except TypeError:
|
|
65
|
+
ok = False
|
|
66
|
+
if not ok:
|
|
67
|
+
self._fail(f"expected {self.v!r} to contain {item!r}")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def to_be_true(self) -> "_Expect":
|
|
71
|
+
if not self.v:
|
|
72
|
+
self._fail(f"expected {self.v!r} to be truthy")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def to_be_false(self) -> "_Expect":
|
|
76
|
+
if self.v:
|
|
77
|
+
self._fail(f"expected {self.v!r} to be falsy")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def to_be_none(self) -> "_Expect":
|
|
81
|
+
if self.v is not None:
|
|
82
|
+
self._fail(f"expected {self.v!r} to be None")
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def to_be_above(self, n: float) -> "_Expect":
|
|
86
|
+
if not self.v > n:
|
|
87
|
+
self._fail(f"expected {self.v!r} to be above {n!r}")
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def to_be_below(self, n: float) -> "_Expect":
|
|
91
|
+
if not self.v < n:
|
|
92
|
+
self._fail(f"expected {self.v!r} to be below {n!r}")
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def to_match(self, pattern: str) -> "_Expect":
|
|
96
|
+
if re.search(pattern, str(self.v)) is None:
|
|
97
|
+
self._fail(f"expected {self.v!r} to match /{pattern}/")
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def to_be_a(self, kind: type | tuple[type, ...]) -> "_Expect":
|
|
101
|
+
if not isinstance(self.v, kind):
|
|
102
|
+
self._fail(f"expected {self.v!r} to be a {getattr(kind, '__name__', kind)}")
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def to_have_length(self, n: int) -> "_Expect":
|
|
106
|
+
if len(self.v) != n:
|
|
107
|
+
self._fail(f"expected length {n}, got {len(self.v)}")
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def to_have_key(self, key: str) -> "_Expect":
|
|
111
|
+
if not isinstance(self.v, dict) or key not in self.v:
|
|
112
|
+
self._fail(f"expected key {key!r} in {self.v!r}")
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _Scope:
|
|
117
|
+
"""py.variables / py.environment / py.globals."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, store: dict[str, str], on_set: Callable[[str, str], None] | None = None) -> None:
|
|
120
|
+
self._store = store
|
|
121
|
+
self._on_set = on_set
|
|
122
|
+
self.changes: list[tuple[str, str]] = []
|
|
123
|
+
|
|
124
|
+
def get(self, name: str, default: Any = None) -> Any:
|
|
125
|
+
return self._store.get(name, default)
|
|
126
|
+
|
|
127
|
+
def set(self, name: str, value: Any) -> None:
|
|
128
|
+
text = value if isinstance(value, str) else json.dumps(value) if isinstance(value, (dict, list)) else str(value)
|
|
129
|
+
self._store[name] = text
|
|
130
|
+
self.changes.append((name, text))
|
|
131
|
+
if self._on_set:
|
|
132
|
+
self._on_set(name, text)
|
|
133
|
+
|
|
134
|
+
def has(self, name: str) -> bool:
|
|
135
|
+
return name in self._store
|
|
136
|
+
|
|
137
|
+
def unset(self, name: str) -> None:
|
|
138
|
+
self._store.pop(name, None)
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> dict[str, str]:
|
|
141
|
+
return dict(self._store)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class JsonObject(dict):
|
|
145
|
+
"""A dict whose keys are also attributes: r.json().data.token, like JavaScript.
|
|
146
|
+
A missing attribute is None (JavaScript's undefined); r.json()["key"] stays strict."""
|
|
147
|
+
|
|
148
|
+
def __getattr__(self, name: str) -> Any:
|
|
149
|
+
if name.startswith("__"):
|
|
150
|
+
raise AttributeError(name)
|
|
151
|
+
return self.get(name)
|
|
152
|
+
|
|
153
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
154
|
+
self[name] = value
|
|
155
|
+
|
|
156
|
+
def __delattr__(self, name: str) -> None:
|
|
157
|
+
self.pop(name, None)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def length(self) -> int: # noqa: D401 - JavaScript habit
|
|
161
|
+
return len(self)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class JsonArray(list):
|
|
165
|
+
@property
|
|
166
|
+
def length(self) -> int:
|
|
167
|
+
return len(self)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def wrap_json(value: Any) -> Any:
|
|
171
|
+
"""Recursively wrap parsed JSON so dotted access works at every level."""
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
return JsonObject({k: wrap_json(v) for k, v in value.items()})
|
|
174
|
+
if isinstance(value, list):
|
|
175
|
+
return JsonArray(wrap_json(v) for v in value)
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _Console:
|
|
180
|
+
def __init__(self, log: Callable[..., None]) -> None:
|
|
181
|
+
self.log = log
|
|
182
|
+
self.info = log
|
|
183
|
+
self.warn = log
|
|
184
|
+
self.error = log
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _Request:
|
|
188
|
+
def __init__(self, req: dict[str, Any]) -> None:
|
|
189
|
+
self.method: str = req.get("method", "GET")
|
|
190
|
+
self.url: str = req.get("url", "")
|
|
191
|
+
self.body: str = req.get("body", "") or ""
|
|
192
|
+
self.body_type: str = req.get("body_type", "none")
|
|
193
|
+
self.headers: dict[str, str] = {r["key"]: r.get("value", "") for r in req.get("headers") or [] if r.get("enabled", True) and r.get("key")}
|
|
194
|
+
self.params: dict[str, str] = {r["key"]: r.get("value", "") for r in req.get("params") or [] if r.get("enabled", True) and r.get("key")}
|
|
195
|
+
self.name: str = req.get("name", "")
|
|
196
|
+
|
|
197
|
+
def apply(self, req: dict[str, Any]) -> dict[str, Any]:
|
|
198
|
+
out = dict(req)
|
|
199
|
+
out["method"] = self.method
|
|
200
|
+
out["url"] = self.url
|
|
201
|
+
out["body"] = self.body
|
|
202
|
+
out["body_type"] = self.body_type
|
|
203
|
+
out["headers"] = [{"key": k, "value": v, "enabled": True} for k, v in self.headers.items()]
|
|
204
|
+
out["params"] = [{"key": k, "value": v, "enabled": True} for k, v in self.params.items()]
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _Response:
|
|
209
|
+
def __init__(self, result: dict[str, Any]) -> None:
|
|
210
|
+
resp = result.get("response") or {}
|
|
211
|
+
self.status_code: int | None = result.get("status_code")
|
|
212
|
+
self.code = self.status_code
|
|
213
|
+
self.reason: str = result.get("reason") or ""
|
|
214
|
+
self.text: str = resp.get("body_text") or ""
|
|
215
|
+
self.headers: dict[str, str] = {k.lower(): v for k, v in resp.get("headers") or []}
|
|
216
|
+
self.time_ms: float = result.get("duration_ms") or 0.0
|
|
217
|
+
self.size: int = resp.get("size") or 0
|
|
218
|
+
self.error: str | None = result.get("error")
|
|
219
|
+
self.ok: bool = result.get("status") == "ok" and (self.status_code or 0) < 400
|
|
220
|
+
self._json = result.get("_json")
|
|
221
|
+
|
|
222
|
+
def json(self) -> Any:
|
|
223
|
+
if self._json is None and self.text:
|
|
224
|
+
self._json = json.loads(self.text)
|
|
225
|
+
if not isinstance(self._json, (JsonObject, JsonArray)):
|
|
226
|
+
self._json = wrap_json(self._json)
|
|
227
|
+
return self._json
|
|
228
|
+
|
|
229
|
+
def header(self, name: str, default: Any = None) -> Any:
|
|
230
|
+
return self.headers.get(name.lower(), default)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class PY:
|
|
234
|
+
def __init__(self, req: dict[str, Any], variables: dict[str, str], result: dict[str, Any] | None,
|
|
235
|
+
set_var: Callable[[str, str, str], str] | None) -> None:
|
|
236
|
+
self.request = _Request(req)
|
|
237
|
+
self.response = _Response(result) if result is not None else None
|
|
238
|
+
self.variables = _Scope(dict(variables))
|
|
239
|
+
self.environment = _Scope({}, (lambda n, v: set_var(n, v, "env")) if set_var else None)
|
|
240
|
+
self.globals = _Scope({}, (lambda n, v: set_var(n, v, "global")) if set_var else None)
|
|
241
|
+
self.tests: list[dict[str, Any]] = []
|
|
242
|
+
self.logs: list[str] = []
|
|
243
|
+
self.info = {"request_name": req.get("name", ""), "iteration": req.get("iteration", 1)}
|
|
244
|
+
self.data = wrap_json(dict(req.get("data_row") or {}))
|
|
245
|
+
|
|
246
|
+
def test(self, name: str, condition: Any = True) -> bool:
|
|
247
|
+
try:
|
|
248
|
+
ok = condition() if callable(condition) else condition
|
|
249
|
+
ok = True if ok is None else bool(ok)
|
|
250
|
+
note = "" if ok else "condition was false"
|
|
251
|
+
except AssertionError as exc:
|
|
252
|
+
ok, note = False, str(exc) or "assertion failed"
|
|
253
|
+
except Exception as exc: # noqa: BLE001
|
|
254
|
+
ok, note = False, f"{type(exc).__name__}: {exc}"
|
|
255
|
+
self.tests.append({"name": str(name), "passed": ok, "note": note})
|
|
256
|
+
return ok
|
|
257
|
+
|
|
258
|
+
def expect(self, value: Any) -> _Expect:
|
|
259
|
+
return _Expect(value)
|
|
260
|
+
|
|
261
|
+
def log(self, *args: Any) -> None:
|
|
262
|
+
self.logs.append(" ".join(str(a) for a in args))
|
|
263
|
+
|
|
264
|
+
# variable lookups honour every scope that was set during the script
|
|
265
|
+
def get(self, name: str, default: Any = None) -> Any:
|
|
266
|
+
for scope in (self.variables, self.environment, self.globals):
|
|
267
|
+
if scope.has(name):
|
|
268
|
+
return scope.get(name)
|
|
269
|
+
return default
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
PM = PY # Postman-style alias
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _syntax_message(exc: SyntaxError, code: str, label: str) -> str:
|
|
276
|
+
line_no = exc.lineno or 1
|
|
277
|
+
lines = code.splitlines()
|
|
278
|
+
src = lines[line_no - 1].strip() if 0 < line_no <= len(lines) else ""
|
|
279
|
+
msg = f"Python syntax error in the {label} script, line {line_no}: {exc.msg}"
|
|
280
|
+
if src:
|
|
281
|
+
msg += f" → {src[:120]}"
|
|
282
|
+
if _JS_HINTS.search(src) or _JS_HINTS.search(code):
|
|
283
|
+
msg += (" (scripts are Python: no function () {...} or =>, use `and`/`or`/`not` and `==`, "
|
|
284
|
+
"and py.test(\"name\", condition) or py.test(\"name\", lambda: ...) instead of a callback)")
|
|
285
|
+
return msg
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_DECL = re.compile(r"^(\s*)(?:const|let|var)\s+(?=[A-Za-z_])", re.MULTILINE)
|
|
289
|
+
_SEMI = re.compile(r";\s*$", re.MULTILINE)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def pythonize(code: str) -> str:
|
|
293
|
+
"""Accept the JavaScript habits that have an obvious Python meaning:
|
|
294
|
+
`const x = ...;` becomes `x = ...`, and a trailing `;` is dropped. Line count is unchanged."""
|
|
295
|
+
code = code.replace("\r\n", "\n")
|
|
296
|
+
code = _DECL.sub(r"\1", code)
|
|
297
|
+
return _SEMI.sub("", code)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _run(code: str, py: PY, label: str) -> dict[str, Any]:
|
|
301
|
+
"""Execute `code` with `py` (and `pm`) available. Never raises."""
|
|
302
|
+
out: dict[str, Any] = {"ok": True, "error": None, "logs": py.logs, "timed_out": False}
|
|
303
|
+
if not (code or "").strip():
|
|
304
|
+
return out
|
|
305
|
+
code = pythonize(code)
|
|
306
|
+
try:
|
|
307
|
+
compiled = compile(code, f"<{label} script>", "exec")
|
|
308
|
+
except SyntaxError as exc:
|
|
309
|
+
out["ok"] = False
|
|
310
|
+
out["error"] = _syntax_message(exc, code, label)
|
|
311
|
+
return out
|
|
312
|
+
env = {"py": py, "pm": py, "json": json, "re": re, "print": py.log, "__name__": f"pyhttp_{label}",
|
|
313
|
+
"true": True, "false": False, "null": None, "console": _Console(py.log)}
|
|
314
|
+
|
|
315
|
+
def target() -> None:
|
|
316
|
+
try:
|
|
317
|
+
exec(compiled, env) # noqa: S102 - user's own script
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
tb = traceback.extract_tb(exc.__traceback__)
|
|
320
|
+
loc = [f for f in tb if f.filename == f"<{label} script>"]
|
|
321
|
+
where = f" (line {loc[-1].lineno})" if loc else ""
|
|
322
|
+
out["ok"] = False
|
|
323
|
+
out["error"] = f"{type(exc).__name__}: {exc}{where}"
|
|
324
|
+
|
|
325
|
+
t = threading.Thread(target=target, daemon=True, name=f"pyhttp-{label}-script")
|
|
326
|
+
t.start()
|
|
327
|
+
t.join(SCRIPT_TIMEOUT)
|
|
328
|
+
if t.is_alive():
|
|
329
|
+
out.update({"ok": False, "timed_out": True, "error": f"script still running after {SCRIPT_TIMEOUT:.0f}s; abandoned"})
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def run_pre_request(code: str, req: dict[str, Any], variables: dict[str, str],
|
|
334
|
+
set_var: Callable[[str, str, str], str] | None) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]:
|
|
335
|
+
"""Returns (possibly modified request, script report, local variable changes)."""
|
|
336
|
+
py = PY(req, variables, None, set_var)
|
|
337
|
+
report = _run(code, py, "pre-request")
|
|
338
|
+
report["variables"] = dict(py.variables.changes)
|
|
339
|
+
if not report["ok"]:
|
|
340
|
+
return req, report, {}
|
|
341
|
+
return py.request.apply(req), report, dict(py.variables.changes)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def run_tests(code: str, req: dict[str, Any], variables: dict[str, str], result: dict[str, Any],
|
|
345
|
+
set_var: Callable[[str, str, str], str] | None) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
346
|
+
"""Returns (tests from the script, script report)."""
|
|
347
|
+
py = PY(req, variables, result, set_var)
|
|
348
|
+
report = _run(code, py, "test")
|
|
349
|
+
tests = list(py.tests)
|
|
350
|
+
if not report["ok"]:
|
|
351
|
+
tests.append({"name": "Test script", "passed": False, "note": report["error"] or "script error"})
|
|
352
|
+
return tests, report
|