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/assertions.py ADDED
@@ -0,0 +1,289 @@
1
+ """Code-free tests (assertions) and value extraction, evaluated after a response.
2
+
3
+ A request can carry a list of tests such as "status equals 200" or
4
+ "json data.token exists", and a list of extractions such as "set env variable
5
+ `token` from json `data.token`". Both are plain dicts so they survive
6
+ import/export, and neither needs a script engine.
7
+
8
+ Test shape:
9
+ {"type": "status"|"time"|"header"|"body"|"json",
10
+ "target": "<header name or json path>", "op": "<operator>",
11
+ "value": "<expected>", "enabled": true}
12
+
13
+ Extraction shape:
14
+ {"name": "token", "source": "json"|"header"|"status"|"body"|"regex",
15
+ "path": "data.token" | "Set-Cookie" | "<regex with one group>",
16
+ "scope": "env"|"global", "enabled": true}
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ from typing import Any
23
+
24
+ from .templating import _Missing, _walk, to_text
25
+
26
+ OPS: dict[str, list[str]] = {
27
+ "status": ["equals", "not_equals", "less_than", "greater_than", "is_2xx", "is_3xx", "is_4xx", "is_5xx", "in"],
28
+ "time": ["less_than", "greater_than"],
29
+ "header": ["exists", "not_exists", "equals", "not_equals", "contains", "not_contains", "matches"],
30
+ "body": ["contains", "not_contains", "equals", "not_equals", "matches", "is_json", "is_empty", "not_empty"],
31
+ "json": ["exists", "not_exists", "equals", "not_equals", "contains", "not_contains", "matches",
32
+ "type_is", "length_equals", "less_than", "greater_than", "is_empty", "not_empty", "is_true", "is_false", "is_null"],
33
+ }
34
+
35
+ _LABEL = {
36
+ "equals": "equals", "not_equals": "does not equal", "less_than": "is less than", "greater_than": "is greater than",
37
+ "is_2xx": "is 2xx", "is_3xx": "is 3xx", "is_4xx": "is 4xx", "is_5xx": "is 5xx", "in": "is one of",
38
+ "exists": "exists", "not_exists": "does not exist", "contains": "contains", "not_contains": "does not contain",
39
+ "matches": "matches", "is_json": "is valid JSON", "is_empty": "is empty", "not_empty": "is not empty",
40
+ "type_is": "is of type", "length_equals": "has length", "is_true": "is true", "is_false": "is false", "is_null": "is null",
41
+ }
42
+
43
+
44
+ def describe(t: dict[str, Any]) -> str:
45
+ """Human name for a test, like Postman's "Status code is 200"."""
46
+ kind, op, target, value = t.get("type", ""), t.get("op", ""), t.get("target", ""), t.get("value", "")
47
+ label = _LABEL.get(op, op)
48
+ subject = {"status": "Status code", "time": "Response time (ms)", "header": f"Header {target}",
49
+ "body": "Body", "json": f"JSON {target}"}.get(kind, kind)
50
+ needs_value = op not in ("exists", "not_exists", "is_json", "is_empty", "not_empty", "is_2xx", "is_3xx",
51
+ "is_4xx", "is_5xx", "is_true", "is_false", "is_null")
52
+ return f"{subject} {label}{' ' + str(value) if needs_value and value != '' else ''}".strip()
53
+
54
+
55
+ def _num(v: Any) -> float | None:
56
+ try:
57
+ return float(v)
58
+ except (TypeError, ValueError):
59
+ return None
60
+
61
+
62
+ def _as_text(v: Any) -> str:
63
+ return v if isinstance(v, str) else to_text(v)
64
+
65
+
66
+ def _json_type(v: Any) -> str:
67
+ if v is None:
68
+ return "null"
69
+ if isinstance(v, bool):
70
+ return "boolean"
71
+ if isinstance(v, (int, float)):
72
+ return "number"
73
+ if isinstance(v, str):
74
+ return "string"
75
+ if isinstance(v, list):
76
+ return "array"
77
+ return "object"
78
+
79
+
80
+ def _loose_equal(actual: Any, expected: str) -> bool:
81
+ if isinstance(actual, bool):
82
+ return expected.strip().lower() in (("true", "1") if actual else ("false", "0"))
83
+ if actual is None:
84
+ return expected.strip().lower() in ("null", "none", "")
85
+ if isinstance(actual, (int, float)):
86
+ n = _num(expected)
87
+ return n is not None and float(actual) == n
88
+ if isinstance(actual, (dict, list)):
89
+ try:
90
+ return actual == json.loads(expected)
91
+ except ValueError:
92
+ return json.dumps(actual, sort_keys=True) == expected
93
+ return str(actual) == expected
94
+
95
+
96
+ def _compare(op: str, actual: Any, expected: str) -> tuple[bool, str]:
97
+ """Shared operators for json/header/body values. Returns (passed, note)."""
98
+ shown = _as_text(actual)
99
+ if len(shown) > 120:
100
+ shown = shown[:117] + "…"
101
+ if op == "equals":
102
+ return _loose_equal(actual, expected), f"actual: {shown}"
103
+ if op == "not_equals":
104
+ return not _loose_equal(actual, expected), f"actual: {shown}"
105
+ if op == "contains":
106
+ if isinstance(actual, list):
107
+ return any(_loose_equal(x, expected) for x in actual), f"{len(actual)} item(s)"
108
+ if isinstance(actual, dict):
109
+ return expected in actual, f"keys: {', '.join(list(actual)[:10])}"
110
+ return expected in _as_text(actual), f"actual: {shown}"
111
+ if op == "not_contains":
112
+ ok, note = _compare("contains", actual, expected)
113
+ return not ok, note
114
+ if op == "matches":
115
+ try:
116
+ return re.search(expected, _as_text(actual)) is not None, f"actual: {shown}"
117
+ except re.error as exc:
118
+ return False, f"invalid regex: {exc}"
119
+ if op in ("less_than", "greater_than"):
120
+ a, e = _num(actual), _num(expected)
121
+ if a is None or e is None:
122
+ return False, f"not a number: {shown}"
123
+ return (a < e) if op == "less_than" else (a > e), f"actual: {shown}"
124
+ if op == "is_empty":
125
+ return actual in ("", None, [], {}), f"actual: {shown}"
126
+ if op == "not_empty":
127
+ return actual not in ("", None, [], {}), f"actual: {shown}"
128
+ if op == "type_is":
129
+ return _json_type(actual) == expected.strip().lower(), f"actual type: {_json_type(actual)}"
130
+ if op == "length_equals":
131
+ n = len(actual) if isinstance(actual, (str, list, dict)) else None
132
+ e = _num(expected)
133
+ return n is not None and e is not None and n == int(e), f"actual length: {n}"
134
+ if op == "is_true":
135
+ return actual is True, f"actual: {shown}"
136
+ if op == "is_false":
137
+ return actual is False, f"actual: {shown}"
138
+ if op == "is_null":
139
+ return actual is None, f"actual: {shown}"
140
+ return False, f"unknown operator {op}"
141
+
142
+
143
+ def _header(result: dict[str, Any], name: str) -> str | None:
144
+ resp = result.get("response") or {}
145
+ for k, v in resp.get("headers", []):
146
+ if k.lower() == name.strip().lower():
147
+ return v
148
+ return None
149
+
150
+
151
+ def _json_value(result: dict[str, Any], path: str) -> Any:
152
+ data = result.get("_json")
153
+ if data is None and (result.get("response") or {}).get("body_text"):
154
+ try:
155
+ data = json.loads(result["response"]["body_text"])
156
+ except ValueError as exc:
157
+ raise _Missing("body is not JSON") from exc
158
+ path = (path or "").strip()
159
+ if path in ("", "$", "."):
160
+ return data
161
+ if path.startswith("$."):
162
+ path = path[2:]
163
+ return _walk(data, path)
164
+
165
+
166
+ def run_tests(tests: list[dict[str, Any]], result: dict[str, Any]) -> list[dict[str, Any]]:
167
+ out: list[dict[str, Any]] = []
168
+ for t in tests or []:
169
+ if not t.get("enabled", True):
170
+ continue
171
+ name = (t.get("name") or "").strip() or describe(t)
172
+ try:
173
+ passed, note = _evaluate(t, result)
174
+ except _Missing as exc:
175
+ passed, note = False, f"missing: {exc}"
176
+ except Exception as exc: # noqa: BLE001 - a broken test must not break the run
177
+ passed, note = False, f"{type(exc).__name__}: {exc}"
178
+ out.append({"name": name, "passed": bool(passed), "note": note})
179
+ return out
180
+
181
+
182
+ def _evaluate(t: dict[str, Any], result: dict[str, Any]) -> tuple[bool, str]:
183
+ kind, op = t.get("type"), t.get("op") or ""
184
+ expected = str(t.get("value", "") if t.get("value") is not None else "")
185
+ if kind == "status":
186
+ code = result.get("status_code")
187
+ if code is None:
188
+ return False, f"no response: {result.get('error') or 'error'}"
189
+ note = f"actual: {code}"
190
+ if op == "equals":
191
+ return str(code) == expected.strip(), note
192
+ if op == "not_equals":
193
+ return str(code) != expected.strip(), note
194
+ if op == "less_than":
195
+ return (_num(expected) is not None) and code < _num(expected), note
196
+ if op == "greater_than":
197
+ return (_num(expected) is not None) and code > _num(expected), note
198
+ if op in ("is_2xx", "is_3xx", "is_4xx", "is_5xx"):
199
+ return code // 100 == int(op[3]), note
200
+ if op == "in":
201
+ wanted = [s.strip() for s in re.split(r"[,\s]+", expected) if s.strip()]
202
+ return str(code) in wanted, note
203
+ return False, f"unknown operator {op}"
204
+ if kind == "time":
205
+ ms = result.get("duration_ms") or 0
206
+ e = _num(expected)
207
+ note = f"actual: {ms:.0f} ms"
208
+ if e is None:
209
+ return False, "expected value is not a number"
210
+ return (ms < e) if op == "less_than" else (ms > e), note
211
+ if kind == "header":
212
+ if result.get("response") is None:
213
+ return False, f"no response: {result.get('error') or 'error'}"
214
+ value = _header(result, t.get("target") or "")
215
+ if op == "exists":
216
+ return value is not None, "present" if value is not None else "absent"
217
+ if op == "not_exists":
218
+ return value is None, "present" if value is not None else "absent"
219
+ if value is None:
220
+ return False, "header absent"
221
+ return _compare(op, value, expected)
222
+ if kind == "body":
223
+ if result.get("response") is None:
224
+ return False, f"no response: {result.get('error') or 'error'}"
225
+ text = result["response"].get("body_text") or ""
226
+ if op == "is_json":
227
+ try:
228
+ json.loads(text)
229
+ return True, "valid JSON"
230
+ except ValueError as exc:
231
+ return False, f"invalid JSON: {exc}"
232
+ return _compare(op, text, expected)
233
+ if kind == "json":
234
+ if result.get("response") is None:
235
+ return False, f"no response: {result.get('error') or 'error'}"
236
+ path = t.get("target") or ""
237
+ try:
238
+ value = _json_value(result, path)
239
+ except _Missing:
240
+ if op == "not_exists":
241
+ return True, "absent"
242
+ return False, f"path not found: {path or '$'}"
243
+ if op == "exists":
244
+ return True, "present"
245
+ if op == "not_exists":
246
+ return False, "present"
247
+ return _compare(op, value, expected)
248
+ return False, f"unknown test type {kind}"
249
+
250
+
251
+ def extract(rules: list[dict[str, Any]], result: dict[str, Any]) -> list[dict[str, Any]]:
252
+ """Evaluate extraction rules. Returns [{name, scope, value, ok, note}] without saving anything."""
253
+ out: list[dict[str, Any]] = []
254
+ for r in rules or []:
255
+ if not r.get("enabled", True):
256
+ continue
257
+ name = (r.get("name") or "").strip()
258
+ if not name:
259
+ continue
260
+ scope = r.get("scope") or "env"
261
+ src, path = r.get("source") or "json", r.get("path") or ""
262
+ try:
263
+ if src == "status":
264
+ value: Any = result.get("status_code")
265
+ if value is None:
266
+ raise _Missing("no response")
267
+ elif src == "header":
268
+ value = _header(result, path)
269
+ if value is None:
270
+ raise _Missing(f"header {path}")
271
+ elif src == "body":
272
+ value = (result.get("response") or {}).get("body_text")
273
+ if value is None:
274
+ raise _Missing("no response")
275
+ elif src == "regex":
276
+ text = (result.get("response") or {}).get("body_text") or ""
277
+ m = re.search(path, text)
278
+ if not m:
279
+ raise _Missing(f"no match for {path}")
280
+ value = m.group(1) if m.groups() else m.group(0)
281
+ else:
282
+ value = _json_value(result, path)
283
+ out.append({"name": name, "scope": scope, "value": _as_text(value), "ok": True,
284
+ "note": f"{src} {path}".strip()})
285
+ except _Missing as exc:
286
+ out.append({"name": name, "scope": scope, "value": None, "ok": False, "note": f"not found: {exc}"})
287
+ except re.error as exc:
288
+ out.append({"name": name, "scope": scope, "value": None, "ok": False, "note": f"invalid regex: {exc}"})
289
+ return out
pyhttp/cli.py ADDED
@@ -0,0 +1,364 @@
1
+ """`pyhttp run`: run a collection headless, for CI (like Postman's newman).
2
+
3
+ python -m pyhttp run # every request in your collection
4
+ python -m pyhttp run "API/Users" Login # folders (by name or path) and requests (by name or id)
5
+ python -m pyhttp run collection.json # a Postman / pyhttp / OpenAPI file, without importing it
6
+ python -m pyhttp run --env QA --junit out/junit.xml --html out/report.html
7
+
8
+ Exit code: 0 when every request passed, 1 when any failed, 2 for usage errors.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import html
15
+ import json
16
+ import sys
17
+ import tempfile
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from xml.etree import ElementTree as ET
22
+
23
+ from . import __version__, db, openapi, postman, runner
24
+
25
+
26
+ def _color(enabled: bool):
27
+ def paint(code: str, text: str) -> str:
28
+ return f"\033[{code}m{text}\033[0m" if enabled else text
29
+ return paint
30
+
31
+
32
+ def build_parser(default_data_dir: Path) -> argparse.ArgumentParser:
33
+ p = argparse.ArgumentParser(prog="pyhttp run", description="Run requests headless and report the results (for CI).")
34
+ p.add_argument("targets", nargs="*", help="folder names or paths (A/B), request names or ids, or a collection "
35
+ "file (Postman v2.1, pyhttp export, OpenAPI). Default: everything")
36
+ p.add_argument("--data-dir", type=Path, default=default_data_dir, help=f"pyhttp data folder (default: {default_data_dir})")
37
+ p.add_argument("-e", "--env", help="environment to use (name); default: the active one")
38
+ p.add_argument("--env-file", type=Path, help="Postman environment file to use instead of a saved environment")
39
+ p.add_argument("--var", action="append", default=[], metavar="KEY=VALUE", help="set a global variable (repeatable)")
40
+ p.add_argument("--tag", action="append", default=[], help="only requests with this tag (repeatable)")
41
+ p.add_argument("-n", "--iterations", type=int, default=1, help="run everything N times")
42
+ p.add_argument("-d", "--data", type=Path, help="CSV or JSON variables file: one row of variable values per iteration")
43
+ p.add_argument("-P", "--parallel", type=int, nargs="?", const=8, default=0, metavar="N",
44
+ help="send everything at once, at most N in flight (default 8), instead of in order")
45
+ p.add_argument("--bail", action="store_true", help="stop at the first failure")
46
+ p.add_argument("--delay", type=float, default=0, metavar="MS", help="pause between requests")
47
+ p.add_argument("--no-fail-on-http-error", action="store_true", help="4xx/5xx responses do not count as failures")
48
+ p.add_argument("--no-fail-on-tests", action="store_true", help="failed tests do not count as failures")
49
+ p.add_argument("--insecure", action="store_true", help="skip SSL certificate verification")
50
+ p.add_argument("--timeout", type=float, help="default request timeout in seconds")
51
+ p.add_argument("--junit", type=Path, help="write a JUnit XML report here")
52
+ p.add_argument("--html", type=Path, help="write an HTML report here")
53
+ p.add_argument("--json", type=Path, help="write the full results as JSON here")
54
+ p.add_argument("--name", help="name of the run (default: the targets)")
55
+ p.add_argument("-q", "--quiet", action="store_true", help="print only the summary")
56
+ p.add_argument("--no-color", action="store_true", help="plain output")
57
+ p.add_argument("--list", action="store_true", help="list what would run, then exit")
58
+ return p
59
+
60
+
61
+ # ---------------------------------------------------------------- selection
62
+
63
+
64
+ def _ordered() -> list[dict[str, Any]]:
65
+ """Every request in collection order: subfolders first, then the folder's own requests."""
66
+ folders = db.list_folders()
67
+ known = {f["id"] for f in folders}
68
+ kids: dict[str, list[dict[str, Any]]] = {}
69
+ for f in folders:
70
+ kids.setdefault(f["parent_id"] if f["parent_id"] in known else "", []).append(f)
71
+ reqs: dict[str, list[dict[str, Any]]] = {}
72
+ for r in db.list_requests():
73
+ reqs.setdefault(r["folder_id"] if r["folder_id"] in known else "", []).append(r)
74
+
75
+ def walk(fid: str) -> list[dict[str, Any]]:
76
+ out: list[dict[str, Any]] = []
77
+ for f in kids.get(fid, []):
78
+ out += walk(f["id"])
79
+ return out + reqs.get(fid, [])
80
+ return walk("")
81
+
82
+
83
+ def _folder_paths() -> dict[str, str]:
84
+ folders = {f["id"]: f for f in db.list_folders()}
85
+ paths: dict[str, str] = {}
86
+ for fid in folders:
87
+ chain, f, seen = [], folders.get(fid), set()
88
+ while f and f["id"] not in seen:
89
+ seen.add(f["id"])
90
+ chain.append(f["name"])
91
+ f = folders.get(f["parent_id"]) if f.get("parent_id") else None
92
+ paths[fid] = "/".join(reversed(chain))
93
+ return paths
94
+
95
+
96
+ def select(targets: list[str], tags: list[str]) -> list[dict[str, Any]]:
97
+ """Requests matching the targets (in collection order, no duplicates). Raises ValueError for unknown targets."""
98
+ ordered = _ordered()
99
+ if not targets:
100
+ chosen = ordered
101
+ else:
102
+ paths = _folder_paths()
103
+ under: dict[str, set[str]] = {} # folder id -> itself and descendants
104
+ for fid, path in paths.items():
105
+ under[fid] = {g for g, p in paths.items() if p == path or p.startswith(path + "/")}
106
+ chosen_ids: list[str] = []
107
+ for t in targets:
108
+ tl = t.strip().strip("/").lower()
109
+ folder_hits = [fid for fid, path in paths.items() if path.lower() == tl or path.lower().rsplit("/", 1)[-1] == tl]
110
+ req_hits = [r["id"] for r in ordered if r["id"] == t or r["name"].lower() == tl]
111
+ if not folder_hits and not req_hits:
112
+ raise ValueError(f"no folder or request named “{t}”")
113
+ fids = set().union(*(under[f] for f in folder_hits)) if folder_hits else set()
114
+ for r in ordered:
115
+ if (r["folder_id"] in fids or r["id"] in req_hits) and r["id"] not in chosen_ids:
116
+ chosen_ids.append(r["id"])
117
+ by_id = {r["id"]: r for r in ordered}
118
+ chosen = [by_id[i] for i in chosen_ids]
119
+ if tags:
120
+ chosen = [r for r in chosen if any(t in r["tags"] for t in tags)]
121
+ return chosen
122
+
123
+
124
+ def load_file(path: Path) -> dict[str, Any]:
125
+ """A collection file in pyhttp's import shape."""
126
+ data = openapi.parse_text(path.read_text(encoding="utf-8-sig"))
127
+ if postman.is_collection(data):
128
+ return postman.from_collection(data)
129
+ if openapi.is_openapi(data):
130
+ return openapi.from_openapi(data)
131
+ if openapi.is_har(data):
132
+ return openapi.from_har(data)
133
+ if isinstance(data, list):
134
+ return {"folders": [], "requests": data, "environments": [], "variables": {}}
135
+ if isinstance(data, dict) and ("requests" in data or "folders" in data):
136
+ return {"folders": data.get("folders") or [], "requests": data.get("requests") or [],
137
+ "environments": data.get("environments") or [], "variables": data.get("variables") or {}}
138
+ raise ValueError(f"{path}: not a Postman collection, pyhttp export, OpenAPI spec or HAR file")
139
+
140
+
141
+ # ------------------------------------------------------------------ reports
142
+
143
+
144
+ def junit_xml(run: dict[str, Any]) -> str:
145
+ results = run["results"]
146
+ suites = ET.Element("testsuites", name=run["name"], tests=str(len(results)),
147
+ failures=str(sum(1 for r in results if r["status"] == "failed")),
148
+ time=f"{sum((r.get('duration_ms') or 0) for r in results) / 1000:.3f}")
149
+ by_iter: dict[int, list[dict[str, Any]]] = {}
150
+ for r in results:
151
+ by_iter.setdefault(int(r.get("iteration") or 1), []).append(r)
152
+ for it, items in by_iter.items():
153
+ suite = ET.SubElement(suites, "testsuite", name=run["name"] + (f" (iteration {it})" if len(by_iter) > 1 else ""),
154
+ tests=str(len(items)), failures=str(sum(1 for r in items if r["status"] == "failed")),
155
+ time=f"{sum((r.get('duration_ms') or 0) for r in items) / 1000:.3f}")
156
+ for r in items:
157
+ name = r["request_name"] + (f" · {r['body_name']}" if r.get("body_name") else "")
158
+ case = ET.SubElement(suite, "testcase", classname=run["name"], name=name,
159
+ time=f"{(r.get('duration_ms') or 0) / 1000:.3f}")
160
+ if r["status"] == "failed":
161
+ problems = []
162
+ if r.get("error"):
163
+ problems.append(r["error"])
164
+ elif r.get("status_code") and r["status_code"] >= 400:
165
+ problems.append(f"HTTP {r['status_code']}")
166
+ problems += [f"{t.get('name')}: {t.get('note') or 'failed'}" for t in r.get("tests") or [] if not t.get("passed")]
167
+ fail = ET.SubElement(case, "failure", message=(problems[0] if problems else "failed")[:500])
168
+ fail.text = "\n".join(problems)
169
+ tests = r.get("tests") or []
170
+ if tests:
171
+ out = ET.SubElement(case, "system-out")
172
+ out.text = "\n".join(f"[{'PASS' if t.get('passed') else 'FAIL'}] {t.get('name')}" + (f" — {t['note']}" if t.get("note") else "") for t in tests)
173
+ ET.indent(suites)
174
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(suites, encoding="unicode")
175
+
176
+
177
+ def html_report(run: dict[str, Any]) -> str:
178
+ e = html.escape
179
+ rows = []
180
+ for r in run["results"]:
181
+ tests = "".join(f"<li class='{'ok' if t.get('passed') else 'bad'}'>{'✓' if t.get('passed') else '✗'} {e(str(t.get('name')))}"
182
+ f"{(' — ' + e(str(t['note']))) if t.get('note') else ''}</li>" for t in r.get("tests") or [])
183
+ rows.append(
184
+ f"<tr class='{e(r['status'])}'><td>{r['position']}</td><td>{e(r.get('method') or '')}</td>"
185
+ f"<td>{e(r['request_name'])}{(' · ' + e(r['body_name'])) if r.get('body_name') else ''}"
186
+ f"{f'<ul>{tests}</ul>' if tests else ''}{(chr(10) + '<div class=bad>' + e(r['error']) + '</div>') if r.get('error') else ''}</td>"
187
+ f"<td>{r.get('status_code') or '—'}</td><td>{(r.get('duration_ms') or 0):.0f} ms</td><td>{e(r['status'])}</td></tr>")
188
+ passed = sum(1 for r in run["results"] if r["status"] != "failed")
189
+ return f"""<!doctype html><html><head><meta charset="utf-8"><title>{e(run['name'])} — pyhttp report</title>
190
+ <style>body{{font:14px system-ui,sans-serif;margin:24px;color:#1f2328}}table{{border-collapse:collapse;width:100%}}
191
+ td,th{{border-bottom:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top}}tr.failed td{{background:#fff1f0}}
192
+ .ok{{color:#1a7f37}}.bad{{color:#cf222e}}ul{{margin:4px 0 0;padding-left:18px;font-size:12px}}.sum{{font-size:16px;margin:8px 0 16px}}</style>
193
+ </head><body><h1>{e(run['name'])}</h1>
194
+ <div class="sum">{passed}/{len(run['results'])} passed · {run['status']} · pyhttp {__version__} · {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(run['started_at']))}</div>
195
+ <table><tr><th>#</th><th>Method</th><th>Request</th><th>Status</th><th>Time</th><th>Result</th></tr>{''.join(rows)}</table></body></html>"""
196
+
197
+
198
+ # --------------------------------------------------------------------- main
199
+
200
+
201
+ def main(argv: list[str], default_data_dir: Path) -> int:
202
+ args = build_parser(default_data_dir).parse_args(argv)
203
+ paint = _color(not args.no_color and sys.stdout.isatty())
204
+ if hasattr(sys.stdout, "reconfigure"):
205
+ try:
206
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
207
+ except Exception: # noqa: BLE001
208
+ pass
209
+
210
+ files = [Path(t) for t in args.targets if Path(t).is_file() and Path(t).suffix.lower() in (".json", ".yaml", ".yml", ".har")]
211
+ tmp: tempfile.TemporaryDirectory[str] | None = None
212
+ if files:
213
+ # run straight from files: a throwaway data folder, the user's collection is not touched
214
+ tmp = tempfile.TemporaryDirectory(prefix="pyhttp-run-", ignore_cleanup_errors=True)
215
+ data_dir = Path(tmp.name)
216
+ targets = [t for t in args.targets if Path(t) not in files]
217
+ args.targets_from_files = True
218
+ else:
219
+ data_dir = args.data_dir.resolve()
220
+ targets = list(args.targets)
221
+ args.targets_from_files = False
222
+ if not (data_dir / "pyhttp.db").exists():
223
+ print(f"No pyhttp data in {data_dir} (use --data-dir, or pass a collection file)", file=sys.stderr)
224
+ return 2
225
+ try:
226
+ db.init(data_dir / "pyhttp.db")
227
+ if files:
228
+ from .app import import_collection
229
+ for f in files:
230
+ conv = load_file(f)
231
+ import_collection(conv["folders"], conv["requests"], conv.get("environments") or [],
232
+ conv.get("variables") or {}, False)
233
+ return _run(args, targets, data_dir, paint)
234
+ except ValueError as exc:
235
+ print(f"pyhttp run: {exc}", file=sys.stderr)
236
+ return 2
237
+ finally:
238
+ db.close()
239
+ if tmp:
240
+ tmp.cleanup()
241
+
242
+
243
+ def _run(args: argparse.Namespace, targets: list[str], data_dir: Path, paint) -> int:
244
+ # environment, variables and settings for this run (restored afterwards when using the user's data)
245
+ restore: dict[str, Any] = {"active_env": db.get_setting("active_env"), "settings": db.app_settings(),
246
+ "variables": db.get_variables()}
247
+ temp_env_id = None
248
+ try:
249
+ if args.env_file:
250
+ env = postman.from_environment(openapi.parse_text(args.env_file.read_text(encoding="utf-8-sig")))
251
+ temp_env_id = db.create_environment(env["name"] + " (pyhttp run)", env["variables"])["id"]
252
+ db.set_setting("active_env", temp_env_id)
253
+ elif args.env:
254
+ match = [e for e in db.list_environments() if e["name"].lower() == args.env.lower() or e["id"] == args.env]
255
+ if not match:
256
+ names = ", ".join(e["name"] for e in db.list_environments()) or "none"
257
+ raise ValueError(f"no environment “{args.env}” (have: {names})")
258
+ db.set_setting("active_env", match[0]["id"])
259
+ if args.var:
260
+ values = db.get_variables()
261
+ for kv in args.var:
262
+ k, sep, v = kv.partition("=")
263
+ if not sep or not k.strip():
264
+ raise ValueError(f"--var expects KEY=VALUE, got “{kv}”")
265
+ values[k.strip()] = v
266
+ db.set_variables(values)
267
+ patch: dict[str, Any] = {}
268
+ if args.insecure:
269
+ patch["verify_ssl"] = False
270
+ if args.timeout:
271
+ patch["default_timeout"] = args.timeout
272
+ if patch:
273
+ db.update_settings(patch)
274
+
275
+ chosen = select(targets, args.tag)
276
+ if not chosen:
277
+ raise ValueError("nothing to run")
278
+ env = db.active_environment()
279
+ if args.list:
280
+ paths = _folder_paths()
281
+ for r in chosen:
282
+ where = paths.get(r["folder_id"] or "", "")
283
+ print(f"{r['method']:7} {(where + '/') if where else ''}{r['name']} {paint('2', r['url'])}")
284
+ return 0
285
+
286
+ data_rows: list[dict[str, Any]] = []
287
+ if args.data:
288
+ from .app import parse_data_file
289
+ data_rows = parse_data_file(args.data.read_text(encoding="utf-8-sig"), args.data.name)
290
+ name = args.name or (" + ".join(targets) if targets else "Collection run")
291
+ options = {
292
+ "name": name, "stop_on_error": args.bail, "fail_on_http_error": not args.no_fail_on_http_error,
293
+ "fail_on_test": not args.no_fail_on_tests, "delay_ms": args.delay,
294
+ "iterations": max(1, args.iterations), "data": data_rows, "source": "cli",
295
+ "parallel": args.parallel > 0, "concurrency": args.parallel or 8,
296
+ }
297
+ if not args.quiet:
298
+ print(paint("1", f"pyhttp {__version__} · {name}") + paint("2", f" · {len(chosen)} request(s)"
299
+ + (f" · environment {env['name']}" if env else "") + (f" · {max(1, args.iterations) if not data_rows else len(data_rows)} iterations" if (args.iterations > 1 or data_rows) else "")))
300
+ run = asyncio.run(_execute(chosen, options, data_dir, args.quiet, paint))
301
+ finally:
302
+ if temp_env_id:
303
+ db.delete_environment(temp_env_id)
304
+ db.set_setting("active_env", restore["active_env"])
305
+ if args.var:
306
+ db.set_variables(restore["variables"])
307
+ if args.insecure or args.timeout:
308
+ db.update_settings({k: restore["settings"][k] for k in ("verify_ssl", "default_timeout")})
309
+
310
+ results = run["results"]
311
+ failed = sum(1 for r in results if r["status"] == "failed")
312
+ tests = [t for r in results for t in r.get("tests") or []]
313
+ tests_failed = sum(1 for t in tests if not t.get("passed"))
314
+ total_ms = sum((r.get("duration_ms") or 0) for r in results)
315
+ summary = (f"{len(results) - failed}/{len(results)} requests passed"
316
+ + (f", {len(tests) - tests_failed}/{len(tests)} tests passed" if tests else "")
317
+ + f" · {total_ms / 1000:.2f}s · {run['status']}")
318
+ print(paint("32;1" if not failed and run["status"] == "completed" else "31;1", summary))
319
+ if failed and not args.quiet:
320
+ print(paint("31", "Failures:"))
321
+ for r in results:
322
+ if r["status"] == "failed":
323
+ why = r.get("error") or (f"HTTP {r['status_code']}" if (r.get("status_code") or 0) >= 400 else "")
324
+ bad = [t.get("name") for t in r.get("tests") or [] if not t.get("passed")]
325
+ print(f" {r['position']}. {r['request_name']}: " + "; ".join(x for x in [why, *bad] if x))
326
+ for path, content in ((args.junit, junit_xml(run) if args.junit else None),
327
+ (args.html, html_report(run) if args.html else None),
328
+ (args.json, json.dumps(run, indent=2, ensure_ascii=False, default=str) if args.json else None)):
329
+ if path and content is not None:
330
+ path.parent.mkdir(parents=True, exist_ok=True)
331
+ path.write_text(content, encoding="utf-8")
332
+ if not args.quiet:
333
+ print(paint("2", f"Report: {path}"))
334
+ if not args.quiet and not args.targets_from_files:
335
+ print(paint("2", f"Outputs: {run['output_dir']}"))
336
+ return 1 if failed or run["status"] not in ("completed",) else 0
337
+
338
+
339
+ async def _execute(chosen: list[dict[str, Any]], options: dict[str, Any], data_dir: Path, quiet: bool, paint) -> dict[str, Any]:
340
+ out_root = data_dir / "outputs"
341
+ out_root.mkdir(parents=True, exist_ok=True)
342
+ manager = runner.RunManager(out_root)
343
+ run = await manager.start_saved([r["id"] for r in chosen], options)
344
+ printed = 0
345
+ while True:
346
+ await asyncio.sleep(0.1)
347
+ cur = db.get_run(run["id"])
348
+ assert cur is not None
349
+ if not quiet:
350
+ for r in cur["results"][printed:]:
351
+ ok = r["status"] != "failed"
352
+ code = r.get("status_code")
353
+ mark = paint("32", "✓") if ok else paint("31", "✗")
354
+ label = r["request_name"] + (f" · {r['body_name']}" if r.get("body_name") else "")
355
+ it = f"[{r.get('iteration')}] " if (options.get("iterations") or 1) > 1 or options.get("data") else ""
356
+ print(f"{mark} {it}{r.get('method', ''):6} {label} " + paint("2", f"{code or 'ERR'} · {(r.get('duration_ms') or 0):.0f} ms"))
357
+ if r.get("error"):
358
+ print(" " + paint("31", r["error"]))
359
+ for t in r.get("tests") or []:
360
+ if not t.get("passed"):
361
+ print(" " + paint("31", f"✗ {t.get('name')}") + (paint("2", f" — {t['note']}") if t.get("note") else ""))
362
+ printed = len(cur["results"])
363
+ if not manager.is_active(run["id"]) and cur["status"] != "running":
364
+ return cur