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.
@@ -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