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/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