reporting-labs 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.
- reporting_labs/__init__.py +163 -0
- reporting_labs/_version.py +1 -0
- reporting_labs/capture/__init__.py +0 -0
- reporting_labs/capture/http.py +181 -0
- reporting_labs/core/__init__.py +0 -0
- reporting_labs/core/collector.py +391 -0
- reporting_labs/core/context.py +44 -0
- reporting_labs/core/envdetect.py +194 -0
- reporting_labs/core/explain.py +414 -0
- reporting_labs/core/history.py +45 -0
- reporting_labs/core/logo.py +26 -0
- reporting_labs/core/mask.py +263 -0
- reporting_labs/core/model.py +175 -0
- reporting_labs/core/options.py +230 -0
- reporting_labs/core/render.py +78 -0
- reporting_labs/core/writer.py +79 -0
- reporting_labs/playwright_support.py +448 -0
- reporting_labs/py.typed +0 -0
- reporting_labs/pytest_plugin.py +407 -0
- reporting_labs/robot/__init__.py +3 -0
- reporting_labs/robot/listener.py +293 -0
- reporting_labs/selenium_support.py +234 -0
- reporting_labs/template.html +1694 -0
- reporting_labs/transport.py +64 -0
- reporting_labs-0.1.0.dist-info/METADATA +163 -0
- reporting_labs-0.1.0.dist-info/RECORD +30 -0
- reporting_labs-0.1.0.dist-info/WHEEL +5 -0
- reporting_labs-0.1.0.dist-info/entry_points.txt +2 -0
- reporting_labs-0.1.0.dist-info/licenses/LICENSE +21 -0
- reporting_labs-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""reportingLabs for Python: one HTML report for pytest, Playwright, Selenium and Robot Framework.
|
|
2
|
+
|
|
3
|
+
Helpers to call from a test:
|
|
4
|
+
|
|
5
|
+
from reporting_labs import meta, log, test_data, step, api, attach
|
|
6
|
+
|
|
7
|
+
meta(priority="P1", owner="asha", feature="checkout")
|
|
8
|
+
log("cart total before coupons: 99.00")
|
|
9
|
+
test_data({"username": "demo", "password": "x"}, "Login")
|
|
10
|
+
with step("Apply coupon"):
|
|
11
|
+
...
|
|
12
|
+
api(method="GET", url="/v1/orders", status=200, duration=120, response_body=body)
|
|
13
|
+
attach("screenshot", png_bytes, "image/png")
|
|
14
|
+
|
|
15
|
+
Secrets (password=..., Bearer ..., tokens) are masked in the report.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import inspect
|
|
21
|
+
import json
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from typing import Any, Callable, Dict, Iterator, Optional, Union
|
|
24
|
+
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from .core import context
|
|
27
|
+
from .core.model import ErrorInfo, now_ms
|
|
28
|
+
|
|
29
|
+
__all__ = ["meta", "log", "test_data", "step", "api", "attach", "RobotListener", "__version__"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name: str) -> Any:
|
|
33
|
+
# `robot --listener reporting_labs.RobotListener`: imported lazily so a pytest project never loads Robot code
|
|
34
|
+
if name == "RobotListener":
|
|
35
|
+
from .robot.listener import ReportingLabsListener
|
|
36
|
+
return ReportingLabsListener
|
|
37
|
+
raise AttributeError(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def meta(**values: Any) -> None:
|
|
41
|
+
"""Attach report metadata to the current test: priority, severity, owner, feature, epic, story, issue... or any key.
|
|
42
|
+
A value can be a list (one chip per value) or a dict (the fields of a multi-parameter link, see `links`)."""
|
|
43
|
+
a = context.current()
|
|
44
|
+
if a is None:
|
|
45
|
+
return
|
|
46
|
+
for k, v in values.items():
|
|
47
|
+
if v is not None:
|
|
48
|
+
a.meta[str(k).lower()] = v
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def log(message: Any, *rest: Any) -> None:
|
|
52
|
+
"""Add a timestamped log line to the current test. Lines containing "error"/"fail" show red, "warn" amber."""
|
|
53
|
+
a = context.current()
|
|
54
|
+
if a is None:
|
|
55
|
+
return
|
|
56
|
+
parts = [str(message)] + [r if isinstance(r, str) else json.dumps(r, default=str) for r in rest]
|
|
57
|
+
a.log(" ".join(parts))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_data(data: Any, name: str = "Test data") -> None:
|
|
61
|
+
"""Attach the data this test uses: a dict (key/value block), a list of dicts (table) or a CSV string (table).
|
|
62
|
+
Sensitive keys are masked."""
|
|
63
|
+
a = context.current()
|
|
64
|
+
if a is not None:
|
|
65
|
+
a.test_data(data, name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def api(method: str, url: str, status: Optional[int] = None, duration: Optional[float] = None, name: Optional[str] = None,
|
|
69
|
+
request_headers: Optional[Dict[str, str]] = None, request_body: Any = None,
|
|
70
|
+
response_headers: Optional[Dict[str, str]] = None, response_body: Any = None) -> None:
|
|
71
|
+
"""Record an API call by hand. requests and httpx calls are recorded automatically."""
|
|
72
|
+
a = context.current()
|
|
73
|
+
if a is None:
|
|
74
|
+
return
|
|
75
|
+
call: Dict[str, Any] = {"method": str(method).upper(), "url": str(url)}
|
|
76
|
+
if status is not None:
|
|
77
|
+
call["status"] = int(status)
|
|
78
|
+
if duration is not None:
|
|
79
|
+
call["duration"] = float(duration)
|
|
80
|
+
if name:
|
|
81
|
+
call["name"] = name
|
|
82
|
+
if request_headers:
|
|
83
|
+
call["requestHeaders"] = dict(request_headers)
|
|
84
|
+
if request_body is not None:
|
|
85
|
+
call["requestBody"] = request_body
|
|
86
|
+
if response_headers:
|
|
87
|
+
call["responseHeaders"] = dict(response_headers)
|
|
88
|
+
if response_body is not None:
|
|
89
|
+
call["responseBody"] = response_body
|
|
90
|
+
a.api_call(call)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def attach(name: str, body: Union[bytes, str, None] = None, content_type: Optional[str] = None, path: Optional[str] = None) -> None:
|
|
94
|
+
"""Attach a file or bytes to the current test: a screenshot, a downloaded PDF, a log. Images are shown inline."""
|
|
95
|
+
a = context.current()
|
|
96
|
+
if a is None:
|
|
97
|
+
return
|
|
98
|
+
if isinstance(body, str):
|
|
99
|
+
body = body.encode("utf-8")
|
|
100
|
+
content_type = content_type or "text/plain"
|
|
101
|
+
if content_type is None:
|
|
102
|
+
ext = (path or name).lower().rsplit(".", 1)[-1] if "." in (path or name) else ""
|
|
103
|
+
content_type = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp", "gif": "image/gif", "webm": "video/webm",
|
|
104
|
+
"mp4": "video/mp4", "txt": "text/plain", "log": "text/plain", "json": "application/json", "html": "text/html", "csv": "text/csv",
|
|
105
|
+
"zip": "application/zip", "pdf": "application/pdf"}.get(ext, "application/octet-stream")
|
|
106
|
+
a.attach(name, body, content_type, path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
test_data.__test__ = False # pytest must not collect the helper when a test module imports it
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _StepContext:
|
|
113
|
+
"""`with step("title"):` and `@step("title")` on a function (sync or async)."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, title: str, category: str = "test.step") -> None:
|
|
116
|
+
self.title, self.category = title, category
|
|
117
|
+
self._st = None
|
|
118
|
+
self._attempt = None
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> "_StepContext":
|
|
121
|
+
self._attempt = context.current()
|
|
122
|
+
if self._attempt is not None:
|
|
123
|
+
self._st = self._attempt.begin_step(self.title, self.category)
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, et, ev, tb) -> bool:
|
|
127
|
+
if self._attempt is not None and self._st is not None:
|
|
128
|
+
self._attempt.end_step(self._st, f"{et.__name__}: {ev}" if et else None)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
async def __aenter__(self) -> "_StepContext":
|
|
132
|
+
return self.__enter__()
|
|
133
|
+
|
|
134
|
+
async def __aexit__(self, et, ev, tb) -> bool:
|
|
135
|
+
return self.__exit__(et, ev, tb)
|
|
136
|
+
|
|
137
|
+
def __call__(self, fn: Callable) -> Callable:
|
|
138
|
+
if inspect.iscoroutinefunction(fn):
|
|
139
|
+
@functools.wraps(fn)
|
|
140
|
+
async def aw(*args: Any, **kw: Any) -> Any:
|
|
141
|
+
async with _StepContext(self.title or fn.__name__, self.category):
|
|
142
|
+
return await fn(*args, **kw)
|
|
143
|
+
return aw
|
|
144
|
+
|
|
145
|
+
@functools.wraps(fn)
|
|
146
|
+
def w(*args: Any, **kw: Any) -> Any:
|
|
147
|
+
with _StepContext(self.title or fn.__name__, self.category):
|
|
148
|
+
return fn(*args, **kw)
|
|
149
|
+
return w
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def step(title: Union[str, Callable, None] = None, category: str = "test.step") -> Any:
|
|
153
|
+
"""A named step in the report. Use as a context manager or a decorator; steps nest.
|
|
154
|
+
|
|
155
|
+
with step("Log in as admin"):
|
|
156
|
+
page.fill("#user", "admin")
|
|
157
|
+
|
|
158
|
+
@step("Open the cart")
|
|
159
|
+
def open_cart(page): ...
|
|
160
|
+
"""
|
|
161
|
+
if callable(title):
|
|
162
|
+
return _StepContext(title.__name__, category)(title)
|
|
163
|
+
return _StepContext(str(title or "step"), category)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Records every requests / httpx call made while a test runs, into the current attempt's API list.
|
|
2
|
+
|
|
3
|
+
Installed once per process by the pytest plugin and the Robot listener. Nothing is recorded outside a test.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from ..core import context
|
|
12
|
+
|
|
13
|
+
_installed: Dict[str, Any] = {}
|
|
14
|
+
_max_body = 64 * 1024
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _headers(h: Any) -> Dict[str, str]:
|
|
18
|
+
try:
|
|
19
|
+
return {str(k): str(v) for k, v in dict(h).items()}
|
|
20
|
+
except Exception:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _body(raw: Any, content_type: str = "") -> Any:
|
|
25
|
+
if raw is None:
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(raw, (dict, list)):
|
|
28
|
+
return raw
|
|
29
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
30
|
+
if len(raw) > _max_body:
|
|
31
|
+
return f"<{len(raw)} bytes, first {_max_body} shown>\n" + bytes(raw[:_max_body]).decode("utf-8", "replace")
|
|
32
|
+
if "json" in content_type or raw[:1] in (b"{", b"["):
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(raw.decode("utf-8"))
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
if any(t in content_type for t in ("image/", "video/", "audio/", "octet-stream", "pdf", "zip")):
|
|
38
|
+
return f"<{len(raw)} bytes {content_type}>"
|
|
39
|
+
return raw.decode("utf-8", "replace")
|
|
40
|
+
if isinstance(raw, str):
|
|
41
|
+
if len(raw) > _max_body:
|
|
42
|
+
return raw[:_max_body] + f"… ({len(raw)} chars)"
|
|
43
|
+
if raw[:1] in "{[":
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(raw)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return raw
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(json.dumps(raw, default=str))
|
|
51
|
+
except Exception:
|
|
52
|
+
return str(raw)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def record(method: str, url: str, status: Optional[int], start: float, req_headers: Any, req_body: Any,
|
|
56
|
+
res_headers: Any = None, res_body: Any = None, error: Optional[str] = None) -> None:
|
|
57
|
+
a = context.current()
|
|
58
|
+
if a is None:
|
|
59
|
+
return
|
|
60
|
+
rh = _headers(req_headers)
|
|
61
|
+
call: Dict[str, Any] = {"method": str(method).upper(), "url": str(url), "duration": round((time.time() - start) * 1000, 1), "requestHeaders": rh}
|
|
62
|
+
if status is not None:
|
|
63
|
+
call["status"] = int(status)
|
|
64
|
+
if req_body is not None:
|
|
65
|
+
call["requestBody"] = _body(req_body, rh.get("Content-Type") or rh.get("content-type") or "")
|
|
66
|
+
if res_headers is not None:
|
|
67
|
+
sh = _headers(res_headers)
|
|
68
|
+
call["responseHeaders"] = sh
|
|
69
|
+
call["responseBody"] = _body(res_body, sh.get("Content-Type") or sh.get("content-type") or "")
|
|
70
|
+
if error:
|
|
71
|
+
call["responseBody"] = error
|
|
72
|
+
a.api_call(call)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install(max_body: int = 64 * 1024) -> None:
|
|
76
|
+
global _max_body
|
|
77
|
+
_max_body = max_body
|
|
78
|
+
_install_requests()
|
|
79
|
+
_install_httpx()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def uninstall() -> None:
|
|
83
|
+
for name, (owner, attr, original) in list(_installed.items()):
|
|
84
|
+
setattr(owner, attr, original)
|
|
85
|
+
_installed.pop(name, None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _install_requests() -> None:
|
|
89
|
+
if "requests" in _installed:
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
import requests
|
|
93
|
+
except ImportError:
|
|
94
|
+
return
|
|
95
|
+
original = requests.Session.send
|
|
96
|
+
|
|
97
|
+
def send(self, request, **kwargs): # type: ignore[no-untyped-def]
|
|
98
|
+
if context.current() is None:
|
|
99
|
+
return original(self, request, **kwargs)
|
|
100
|
+
start = time.time()
|
|
101
|
+
try:
|
|
102
|
+
resp = original(self, request, **kwargs)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
record(request.method, request.url, None, start, request.headers, request.body, error=f"{type(e).__name__}: {e}")
|
|
105
|
+
raise
|
|
106
|
+
body: Any = None
|
|
107
|
+
try:
|
|
108
|
+
if not kwargs.get("stream"):
|
|
109
|
+
body = resp.content
|
|
110
|
+
except Exception:
|
|
111
|
+
body = None
|
|
112
|
+
record(request.method, request.url, resp.status_code, start, request.headers, request.body, resp.headers, body)
|
|
113
|
+
return resp
|
|
114
|
+
|
|
115
|
+
requests.Session.send = send # type: ignore[assignment]
|
|
116
|
+
_installed["requests"] = (requests.Session, "send", original)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _install_httpx() -> None:
|
|
120
|
+
if "httpx" in _installed:
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
import httpx
|
|
124
|
+
except ImportError:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
def req_body(request: Any) -> Any:
|
|
128
|
+
try:
|
|
129
|
+
return request.content or None
|
|
130
|
+
except Exception:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def res_body(resp: Any) -> Any:
|
|
134
|
+
try:
|
|
135
|
+
return resp.content if not resp.is_stream_consumed and hasattr(resp, "_content") else resp.content
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
original = httpx.Client.send
|
|
140
|
+
|
|
141
|
+
def send(self, request, **kwargs): # type: ignore[no-untyped-def]
|
|
142
|
+
if context.current() is None:
|
|
143
|
+
return original(self, request, **kwargs)
|
|
144
|
+
start = time.time()
|
|
145
|
+
try:
|
|
146
|
+
resp = original(self, request, **kwargs)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
record(request.method, str(request.url), None, start, request.headers, req_body(request), error=f"{type(e).__name__}: {e}")
|
|
149
|
+
raise
|
|
150
|
+
try:
|
|
151
|
+
if not kwargs.get("stream"):
|
|
152
|
+
resp.read()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
record(request.method, str(request.url), resp.status_code, start, request.headers, req_body(request), resp.headers, res_body(resp) if not kwargs.get("stream") else None)
|
|
156
|
+
return resp
|
|
157
|
+
|
|
158
|
+
httpx.Client.send = send # type: ignore[assignment]
|
|
159
|
+
_installed["httpx"] = (httpx.Client, "send", original)
|
|
160
|
+
|
|
161
|
+
aoriginal = httpx.AsyncClient.send
|
|
162
|
+
|
|
163
|
+
async def asend(self, request, **kwargs): # type: ignore[no-untyped-def]
|
|
164
|
+
if context.current() is None:
|
|
165
|
+
return await aoriginal(self, request, **kwargs)
|
|
166
|
+
start = time.time()
|
|
167
|
+
try:
|
|
168
|
+
resp = await aoriginal(self, request, **kwargs)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
record(request.method, str(request.url), None, start, request.headers, req_body(request), error=f"{type(e).__name__}: {e}")
|
|
171
|
+
raise
|
|
172
|
+
try:
|
|
173
|
+
if not kwargs.get("stream"):
|
|
174
|
+
await resp.aread()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
record(request.method, str(request.url), resp.status_code, start, request.headers, req_body(request), resp.headers, res_body(resp) if not kwargs.get("stream") else None)
|
|
178
|
+
return resp
|
|
179
|
+
|
|
180
|
+
httpx.AsyncClient.send = asend # type: ignore[assignment]
|
|
181
|
+
_installed["httpx_async"] = (httpx.AsyncClient, "send", aoriginal)
|
|
File without changes
|