robotframework-request-logger 0.1.0__tar.gz
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.
- robotframework_request_logger-0.1.0/LICENSE +21 -0
- robotframework_request_logger-0.1.0/PKG-INFO +74 -0
- robotframework_request_logger-0.1.0/README.md +54 -0
- robotframework_request_logger-0.1.0/pyproject.toml +48 -0
- robotframework_request_logger-0.1.0/src/RequestLogger.py +5 -0
- robotframework_request_logger-0.1.0/src/request_logger/__init__.py +235 -0
- robotframework_request_logger-0.1.0/src/request_logger/models.py +32 -0
- robotframework_request_logger-0.1.0/src/request_logger/protection.py +114 -0
- robotframework_request_logger-0.1.0/src/request_logger/render.py +73 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Angel Molina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-request-logger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Protected HTTP request and response console logging for Robot Framework with Rich.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: robotframework,requests,console,rich
|
|
8
|
+
Author: Angel Molina
|
|
9
|
+
Requires-Python: >=3.12,<4
|
|
10
|
+
Classifier: Framework :: Robot Framework :: Library
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Requires-Dist: requests (>=2.32,<3)
|
|
14
|
+
Requires-Dist: rich (>=14,<15)
|
|
15
|
+
Requires-Dist: robotframework (>=7.5,<8)
|
|
16
|
+
Project-URL: Documentation, https://angel-valdezzz.github.io/robotframework-request-logger/
|
|
17
|
+
Project-URL: Repository, https://github.com/angel-valdezzz/robotframework-request-logger
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Robot Framework Request Logger
|
|
21
|
+
|
|
22
|
+
HTTP request/response logging in the console with Rich. Independent of RequestReporter:
|
|
23
|
+
this library sends no HTTP requests and executes no assertions. Robot's console and
|
|
24
|
+
exit code remain unchanged. Output is buffered until each test finishes.
|
|
25
|
+
|
|
26
|
+
[Manual de usuario](https://angel-valdezzz.github.io/robotframework-request-logger/)
|
|
27
|
+
· [Keywords](https://angel-valdezzz.github.io/robotframework-request-logger/keywords/)
|
|
28
|
+
· [Ejemplos visuales](https://angel-valdezzz.github.io/robotframework-request-logger/console/)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
poetry add robotframework-request-logger
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```robotframework
|
|
35
|
+
*** Settings ***
|
|
36
|
+
Library RequestsLibrary
|
|
37
|
+
Library RequestLogger mode=summary
|
|
38
|
+
|
|
39
|
+
*** Test Cases ***
|
|
40
|
+
Health
|
|
41
|
+
${response}= GET http://localhost:8000/health expected_status=anything
|
|
42
|
+
${id}= Log Response Health ${response}
|
|
43
|
+
Should Be Equal As Integers ${response.status_code} 200
|
|
44
|
+
Log Assertion Result ${id} HTTP status PASS
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`mode=summary` shows every exchange briefly; `failures` shows full details of explicitly
|
|
48
|
+
failed assertions or request errors; `full` shows every exchange in detail. An HTTP
|
|
49
|
+
4xx/5xx response alone does not make a test fail. Assertions accept PASS/FAIL only.
|
|
50
|
+
|
|
51
|
+
Default operation complements Robot's normal console. For examples showing only this
|
|
52
|
+
library, use `poetry run robot --console none tests/visual.robot`. `quiet` still permits
|
|
53
|
+
Robot errors and warnings. Native `output.xml`, `log.html` and exit codes are preserved.
|
|
54
|
+
|
|
55
|
+
## Development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
poetry install
|
|
59
|
+
poetry run python scripts/verify.py
|
|
60
|
+
poetry run ruff check .
|
|
61
|
+
poetry run ruff format .
|
|
62
|
+
poetry run robocop check tests
|
|
63
|
+
poetry run robocop format tests
|
|
64
|
+
poetry run python scripts/build_docs.py
|
|
65
|
+
poetry build
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Known limits: buffering is in memory and output occurs at end_test. Abrupt termination
|
|
69
|
+
may lose pending logs. Pabot workers can interleave output; no cross-process ordering
|
|
70
|
+
is promised. JSON/text bodies are complete; binary/multipart bodies are summarized.
|
|
71
|
+
XML field redaction is not supported. Protection affects this library's output only,
|
|
72
|
+
not RequestsLibrary/Robot's own logs. Typer and pytest/unittest integration are outside
|
|
73
|
+
this version. Unit tests of implementation use Python's standard test tools.
|
|
74
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Robot Framework Request Logger
|
|
2
|
+
|
|
3
|
+
HTTP request/response logging in the console with Rich. Independent of RequestReporter:
|
|
4
|
+
this library sends no HTTP requests and executes no assertions. Robot's console and
|
|
5
|
+
exit code remain unchanged. Output is buffered until each test finishes.
|
|
6
|
+
|
|
7
|
+
[Manual de usuario](https://angel-valdezzz.github.io/robotframework-request-logger/)
|
|
8
|
+
· [Keywords](https://angel-valdezzz.github.io/robotframework-request-logger/keywords/)
|
|
9
|
+
· [Ejemplos visuales](https://angel-valdezzz.github.io/robotframework-request-logger/console/)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
poetry add robotframework-request-logger
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```robotframework
|
|
16
|
+
*** Settings ***
|
|
17
|
+
Library RequestsLibrary
|
|
18
|
+
Library RequestLogger mode=summary
|
|
19
|
+
|
|
20
|
+
*** Test Cases ***
|
|
21
|
+
Health
|
|
22
|
+
${response}= GET http://localhost:8000/health expected_status=anything
|
|
23
|
+
${id}= Log Response Health ${response}
|
|
24
|
+
Should Be Equal As Integers ${response.status_code} 200
|
|
25
|
+
Log Assertion Result ${id} HTTP status PASS
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`mode=summary` shows every exchange briefly; `failures` shows full details of explicitly
|
|
29
|
+
failed assertions or request errors; `full` shows every exchange in detail. An HTTP
|
|
30
|
+
4xx/5xx response alone does not make a test fail. Assertions accept PASS/FAIL only.
|
|
31
|
+
|
|
32
|
+
Default operation complements Robot's normal console. For examples showing only this
|
|
33
|
+
library, use `poetry run robot --console none tests/visual.robot`. `quiet` still permits
|
|
34
|
+
Robot errors and warnings. Native `output.xml`, `log.html` and exit codes are preserved.
|
|
35
|
+
|
|
36
|
+
## Development
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
poetry install
|
|
40
|
+
poetry run python scripts/verify.py
|
|
41
|
+
poetry run ruff check .
|
|
42
|
+
poetry run ruff format .
|
|
43
|
+
poetry run robocop check tests
|
|
44
|
+
poetry run robocop format tests
|
|
45
|
+
poetry run python scripts/build_docs.py
|
|
46
|
+
poetry build
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Known limits: buffering is in memory and output occurs at end_test. Abrupt termination
|
|
50
|
+
may lose pending logs. Pabot workers can interleave output; no cross-process ordering
|
|
51
|
+
is promised. JSON/text bodies are complete; binary/multipart bodies are summarized.
|
|
52
|
+
XML field redaction is not supported. Protection affects this library's output only,
|
|
53
|
+
not RequestsLibrary/Robot's own logs. Typer and pytest/unittest integration are outside
|
|
54
|
+
this version. Unit tests of implementation use Python's standard test tools.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "robotframework-request-logger"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Protected HTTP request and response console logging for Robot Framework with Rich."
|
|
5
|
+
authors = [{name = "Angel Molina"}]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
requires-python = ">=3.12,<4"
|
|
9
|
+
dependencies = ["robotframework>=7.5,<8", "requests>=2.32,<3", "rich>=14,<15"]
|
|
10
|
+
keywords = ["robotframework", "requests", "console", "rich"]
|
|
11
|
+
classifiers = ["Framework :: Robot Framework :: Library", "Programming Language :: Python :: 3", "Development Status :: 3 - Alpha"]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Repository = "https://github.com/angel-valdezzz/robotframework-request-logger"
|
|
15
|
+
Documentation = "https://angel-valdezzz.github.io/robotframework-request-logger/"
|
|
16
|
+
|
|
17
|
+
[tool.poetry]
|
|
18
|
+
packages = [{include = "request_logger", from = "src"}, {include = "RequestLogger.py", from = "src"}]
|
|
19
|
+
requires-poetry = ">=2.5.1,<3"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.group.dev.dependencies]
|
|
22
|
+
robotframework-requests = "0.9.7"
|
|
23
|
+
robotframework-robocop = "^9.1.0"
|
|
24
|
+
ruff = ">=0.16,<1"
|
|
25
|
+
mypy = ">=1.15,<2"
|
|
26
|
+
types-requests = "*"
|
|
27
|
+
twine = ">=6,<7"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.group.docs.dependencies]
|
|
30
|
+
mkdocs-material = ">=9.6,<10"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py312"
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
python_version = "3.12"
|
|
39
|
+
disallow_untyped_defs = true
|
|
40
|
+
check_untyped_defs = true
|
|
41
|
+
ignore_missing_imports = true
|
|
42
|
+
[tool.robocop]
|
|
43
|
+
target-version = 7
|
|
44
|
+
[tool.robocop.lint]
|
|
45
|
+
threshold = "E"
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["poetry-core>=2.0,<3"]
|
|
48
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""HTTP console logging for Robot Framework; does not execute requests or assertions.
|
|
2
|
+
|
|
3
|
+
```robotframework
|
|
4
|
+
*** Settings ***
|
|
5
|
+
Library RequestsLibrary
|
|
6
|
+
Library RequestLogger mode=summary
|
|
7
|
+
|
|
8
|
+
*** Test Cases ***
|
|
9
|
+
Health
|
|
10
|
+
${response}= GET ${URL} expected_status=anything
|
|
11
|
+
${id}= Log Response Health ${response}
|
|
12
|
+
Should Be Equal As Integers ${response.status_code} 200
|
|
13
|
+
Log Assertion Result ${id} HTTP status PASS
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Output is buffered until end_test so failures can be filtered and secrets learned
|
|
17
|
+
later in a case can be hidden. Robot's native console and return code are unchanged.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from requests import PreparedRequest, Response
|
|
24
|
+
from rich.console import Console, Group
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
from robot.api import logger
|
|
27
|
+
from robot.api.deco import keyword, library
|
|
28
|
+
|
|
29
|
+
from .models import Assertion, Exchange
|
|
30
|
+
from .protection import Protector
|
|
31
|
+
from .render import exchange
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
_HEADERS = "Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-API-Key"
|
|
35
|
+
_FIELDS = "access_token,refresh_token,client_secret,password,token,api_key"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@library(scope="GLOBAL", version=__version__, doc_format="MARKDOWN", auto_keywords=False)
|
|
39
|
+
class RequestLogger:
|
|
40
|
+
"""Case-local HTTP console output with no dependency on RequestReporter."""
|
|
41
|
+
|
|
42
|
+
ROBOT_LISTENER_API_VERSION = 3
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
mode: str = "summary",
|
|
47
|
+
redact_headers: str = _HEADERS,
|
|
48
|
+
redact_body_fields: str = _FIELDS,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Choose summary (default), failures or full.
|
|
51
|
+
|
|
52
|
+
summary prints all exchanges briefly; failures prints full details only for
|
|
53
|
+
explicitly failed assertions or request errors; full prints all details.
|
|
54
|
+
HTTP 4xx/5xx alone does not imply test failure. Assertions accept PASS/FAIL only.
|
|
55
|
+
No HTTP call or assertion is executed by this library. No console mode is
|
|
56
|
+
changed. In CI output is static, with Rich terminal detection and plain text
|
|
57
|
+
fallback. Full text/JSON bodies are kept; binary/multipart bodies are summarized.
|
|
58
|
+
XML field redaction is not supported. Redaction affects this console only.
|
|
59
|
+
|
|
60
|
+
```robotframework
|
|
61
|
+
Library RequestLogger mode=full
|
|
62
|
+
```
|
|
63
|
+
"""
|
|
64
|
+
if mode not in {"summary", "failures", "full"}:
|
|
65
|
+
raise ValueError("mode must be summary, failures or full")
|
|
66
|
+
self.mode = mode
|
|
67
|
+
self.redact_headers = redact_headers
|
|
68
|
+
self.redact_body_fields = redact_body_fields
|
|
69
|
+
self.ROBOT_LIBRARY_LISTENER = self
|
|
70
|
+
self.records: list[Exchange] = []
|
|
71
|
+
self.protector = Protector(redact_headers, redact_body_fields)
|
|
72
|
+
self.active = False
|
|
73
|
+
|
|
74
|
+
def start_test(self, data: Any, result: Any) -> None:
|
|
75
|
+
self.records = []
|
|
76
|
+
self.protector = Protector(self.redact_headers, self.redact_body_fields)
|
|
77
|
+
self.active = True
|
|
78
|
+
|
|
79
|
+
def end_test(self, data: Any, result: Any) -> None:
|
|
80
|
+
try:
|
|
81
|
+
visible = [r for r in self.records if self.mode != "failures" or r.failed]
|
|
82
|
+
items = [exchange(r, self.mode != "summary", self.protector) for r in visible]
|
|
83
|
+
if result.status in {"FAIL", "SKIP"}:
|
|
84
|
+
items.append(
|
|
85
|
+
Text(
|
|
86
|
+
self.protector.text(f"Robot {result.status}: {result.message}"),
|
|
87
|
+
style="red" if result.status == "FAIL" else "yellow",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
if items:
|
|
91
|
+
# Robot captures sys.stdout during keywords. Use its original stream,
|
|
92
|
+
# while retaining Rich's detection for redirected/non-interactive output.
|
|
93
|
+
console = Console(
|
|
94
|
+
file=sys.__stdout__ or sys.stdout,
|
|
95
|
+
force_interactive=False,
|
|
96
|
+
markup=False,
|
|
97
|
+
highlight=False,
|
|
98
|
+
safe_box=True,
|
|
99
|
+
)
|
|
100
|
+
console.print(Group(*items))
|
|
101
|
+
except (OSError, UnicodeError) as error:
|
|
102
|
+
logger.warn(f"RequestLogger output unavailable: {type(error).__name__}")
|
|
103
|
+
finally:
|
|
104
|
+
self.active = False
|
|
105
|
+
self.records = []
|
|
106
|
+
|
|
107
|
+
def _require_case(self) -> None:
|
|
108
|
+
if not self.active:
|
|
109
|
+
raise RuntimeError("Logger keywords must run inside an active Robot test")
|
|
110
|
+
|
|
111
|
+
def _find(self, request_id: str) -> Exchange:
|
|
112
|
+
self._require_case()
|
|
113
|
+
for r in self.records:
|
|
114
|
+
if r.id == request_id:
|
|
115
|
+
return r
|
|
116
|
+
raise ValueError(f"Unknown request ID in current test: {request_id}")
|
|
117
|
+
|
|
118
|
+
@keyword("Log Request")
|
|
119
|
+
def log_request(self, name: str, request: PreparedRequest) -> str:
|
|
120
|
+
"""Register an already prepared requests.PreparedRequest; return a case-local ID.
|
|
121
|
+
|
|
122
|
+
Does not send HTTP. Usually Log Response alone is sufficient. Passing the same
|
|
123
|
+
prepared request to Log Request and Log Response reuses the ID and one record.
|
|
124
|
+
A request without a response is not automatically classified as a failed test.
|
|
125
|
+
|
|
126
|
+
```robotframework
|
|
127
|
+
${id}= Log Request Health ${response.request}
|
|
128
|
+
```
|
|
129
|
+
"""
|
|
130
|
+
self._require_case()
|
|
131
|
+
if not isinstance(request, PreparedRequest):
|
|
132
|
+
raise TypeError("request must be a requests.PreparedRequest")
|
|
133
|
+
found = next((r for r in self.records if r.original_request is request), None)
|
|
134
|
+
if found:
|
|
135
|
+
return found.id
|
|
136
|
+
headers = self.protector.header_values(request.headers)
|
|
137
|
+
url = self.protector.url(request.url or "")
|
|
138
|
+
request_body = self.protector.body(request.body, request.headers.get("Content-Type", ""))
|
|
139
|
+
record = Exchange(
|
|
140
|
+
id=f"request-{len(self.records) + 1}",
|
|
141
|
+
name=name,
|
|
142
|
+
method=request.method or "",
|
|
143
|
+
url=url,
|
|
144
|
+
request_headers=headers,
|
|
145
|
+
request_body=request_body,
|
|
146
|
+
original_request=request,
|
|
147
|
+
)
|
|
148
|
+
self.records.append(record)
|
|
149
|
+
return record.id
|
|
150
|
+
|
|
151
|
+
@keyword("Log Response")
|
|
152
|
+
def log_response(self, name: str, response: Response) -> str:
|
|
153
|
+
"""Register an existing requests.Response and its prepared request; return its ID.
|
|
154
|
+
|
|
155
|
+
Does not execute HTTP. Capture before validating status or parsing JSON.
|
|
156
|
+
Headers, bodies, elapsed time and URL are shown according to mode at test end.
|
|
157
|
+
A repeated call for the same prepared request updates its existing record.
|
|
158
|
+
|
|
159
|
+
```robotframework
|
|
160
|
+
${response}= GET ${URL} expected_status=anything
|
|
161
|
+
${id}= Log Response Health ${response}
|
|
162
|
+
```
|
|
163
|
+
"""
|
|
164
|
+
self._require_case()
|
|
165
|
+
if not isinstance(response, Response) or response.request is None:
|
|
166
|
+
raise TypeError("response must be a requests.Response with a prepared request")
|
|
167
|
+
request_id = self.log_request(name, response.request)
|
|
168
|
+
r = self._find(request_id)
|
|
169
|
+
r.response_headers = self.protector.header_values(response.headers)
|
|
170
|
+
r.response_body = self.protector.body(
|
|
171
|
+
response.content, response.headers.get("Content-Type", "")
|
|
172
|
+
)
|
|
173
|
+
r.status_code = response.status_code
|
|
174
|
+
r.duration_ms = round(response.elapsed.total_seconds() * 1000, 2)
|
|
175
|
+
return request_id
|
|
176
|
+
|
|
177
|
+
@keyword("Log Request Error")
|
|
178
|
+
def log_request_error(
|
|
179
|
+
self, name: str, method: str, url: str, message: str, request_id: str | None = None
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Register an HTTP attempt without a response; return its ID.
|
|
182
|
+
|
|
183
|
+
No HTTP status or response body is fabricated. Optional request_id associates
|
|
184
|
+
the error with an earlier Log Request. This keyword does not fail the test:
|
|
185
|
+
propagate the original failure after logging it.
|
|
186
|
+
|
|
187
|
+
```robotframework
|
|
188
|
+
TRY
|
|
189
|
+
${response}= GET ${URL} timeout=10
|
|
190
|
+
EXCEPT AS ${error}
|
|
191
|
+
Log Request Error Health GET ${URL} ${error}
|
|
192
|
+
Fail ${error}
|
|
193
|
+
END
|
|
194
|
+
```
|
|
195
|
+
"""
|
|
196
|
+
self._require_case()
|
|
197
|
+
protected_url = self.protector.url(url)
|
|
198
|
+
if request_id:
|
|
199
|
+
r = self._find(request_id)
|
|
200
|
+
if r.status_code is not None:
|
|
201
|
+
raise ValueError("A received HTTP response is not a request transport error")
|
|
202
|
+
else:
|
|
203
|
+
r = Exchange(
|
|
204
|
+
id=f"request-{len(self.records) + 1}",
|
|
205
|
+
name=name,
|
|
206
|
+
method=method.upper(),
|
|
207
|
+
url=protected_url,
|
|
208
|
+
)
|
|
209
|
+
self.records.append(r)
|
|
210
|
+
r.error = self.protector.text(message) or "HTTP request failed without a response"
|
|
211
|
+
return r.id
|
|
212
|
+
|
|
213
|
+
@keyword("Log Assertion Result")
|
|
214
|
+
def log_assertion_result(
|
|
215
|
+
self, request_id: str, label: str, status: str, message: str = ""
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Associate an already obtained PASS/FAIL result; does not execute assertions.
|
|
218
|
+
|
|
219
|
+
status must be PASS or FAIL. Unknown IDs and SKIP are rejected. This records
|
|
220
|
+
evidence only: a logged FAIL does not change Robot's native test result.
|
|
221
|
+
Use Robot's actual assertion and preserve its failure when obtaining status.
|
|
222
|
+
|
|
223
|
+
```robotframework
|
|
224
|
+
${status} ${message}= Run Keyword And Ignore Error
|
|
225
|
+
... Should Be Equal As Integers ${response.status_code} 200
|
|
226
|
+
Log Assertion Result ${id} HTTP status ${status} ${message}
|
|
227
|
+
IF $status == 'FAIL'
|
|
228
|
+
Fail ${message}
|
|
229
|
+
END
|
|
230
|
+
```
|
|
231
|
+
"""
|
|
232
|
+
r = self._find(request_id)
|
|
233
|
+
if status not in {"PASS", "FAIL"}:
|
|
234
|
+
raise ValueError("Assertion status must be PASS or FAIL")
|
|
235
|
+
r.assertions.append(Assertion(label, status, message))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Case-local HTTP records; no Robot execution objects or reporter dependency."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Assertion:
|
|
9
|
+
label: str
|
|
10
|
+
status: str
|
|
11
|
+
message: str = ""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Exchange:
|
|
16
|
+
id: str
|
|
17
|
+
name: str
|
|
18
|
+
method: str
|
|
19
|
+
url: str
|
|
20
|
+
request_headers: dict[str, str] = field(default_factory=dict)
|
|
21
|
+
request_body: Any = None
|
|
22
|
+
response_headers: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
response_body: Any = None
|
|
24
|
+
status_code: int | None = None
|
|
25
|
+
duration_ms: float | None = None
|
|
26
|
+
error: str = ""
|
|
27
|
+
assertions: list[Assertion] = field(default_factory=list)
|
|
28
|
+
original_request: Any = field(default=None, repr=False)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def failed(self) -> bool:
|
|
32
|
+
return bool(self.error) or any(a.status == "FAIL" for a in self.assertions)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Protect secrets and terminal output without depending on an HTML reporter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
MASK = "[REDACTED]"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def safe_text(value: str) -> str:
|
|
12
|
+
"""Remove terminal control sequences from untrusted HTTP and Robot text."""
|
|
13
|
+
value = re.sub(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)", "", value)
|
|
14
|
+
value = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", value)
|
|
15
|
+
return re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", "", value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Protector:
|
|
19
|
+
def __init__(self, headers: str, fields: str) -> None:
|
|
20
|
+
self.headers = {v.strip().lower() for v in headers.split(",") if v.strip()}
|
|
21
|
+
self.fields = {v.strip().lower() for v in fields.split(",") if v.strip()}
|
|
22
|
+
self.secrets: set[str] = set()
|
|
23
|
+
|
|
24
|
+
def remember(self, value: Any) -> None:
|
|
25
|
+
if isinstance(value, str) and value:
|
|
26
|
+
self.secrets.add(value)
|
|
27
|
+
if value.lower().startswith("bearer "):
|
|
28
|
+
self.secrets.add(value[7:])
|
|
29
|
+
elif isinstance(value, dict):
|
|
30
|
+
for v in value.values():
|
|
31
|
+
self.remember(v)
|
|
32
|
+
elif isinstance(value, (list, tuple)):
|
|
33
|
+
for v in value:
|
|
34
|
+
self.remember(v)
|
|
35
|
+
|
|
36
|
+
def text(self, value: str) -> str:
|
|
37
|
+
for secret in sorted(self.secrets, key=len, reverse=True):
|
|
38
|
+
value = value.replace(secret, MASK)
|
|
39
|
+
return safe_text(value)
|
|
40
|
+
|
|
41
|
+
def clean(self, value: Any) -> Any:
|
|
42
|
+
if isinstance(value, dict):
|
|
43
|
+
out = {}
|
|
44
|
+
for k, v in value.items():
|
|
45
|
+
if str(k).lower() in self.fields:
|
|
46
|
+
self.remember(v)
|
|
47
|
+
out[str(k)] = MASK
|
|
48
|
+
else:
|
|
49
|
+
out[str(k)] = self.clean(v)
|
|
50
|
+
return out
|
|
51
|
+
if isinstance(value, (list, tuple)):
|
|
52
|
+
return [self.clean(v) for v in value]
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
return self.text(value)
|
|
55
|
+
if value is None or isinstance(value, (int, float, bool)):
|
|
56
|
+
return value
|
|
57
|
+
return self.text(str(value))
|
|
58
|
+
|
|
59
|
+
def header_values(self, headers: Any) -> dict[str, str]:
|
|
60
|
+
out = {}
|
|
61
|
+
for k, v in headers.items():
|
|
62
|
+
if k.lower() in self.headers:
|
|
63
|
+
self.remember(str(v))
|
|
64
|
+
out[str(k)] = MASK
|
|
65
|
+
else:
|
|
66
|
+
out[str(k)] = self.text(str(v))
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
def url(self, url: str) -> str:
|
|
70
|
+
parts = urlsplit(url)
|
|
71
|
+
pairs = []
|
|
72
|
+
for k, v in parse_qsl(parts.query, keep_blank_values=True):
|
|
73
|
+
if k.lower() in self.fields:
|
|
74
|
+
self.remember(v)
|
|
75
|
+
v = MASK
|
|
76
|
+
pairs.append((k, v))
|
|
77
|
+
netloc = parts.netloc
|
|
78
|
+
if "@" in netloc:
|
|
79
|
+
credentials, host = netloc.rsplit("@", 1)
|
|
80
|
+
self.remember(credentials)
|
|
81
|
+
if parts.password:
|
|
82
|
+
self.remember(unquote(parts.password))
|
|
83
|
+
netloc = MASK + "@" + host
|
|
84
|
+
return self.text(
|
|
85
|
+
urlunsplit((parts.scheme, netloc, parts.path, urlencode(pairs), parts.fragment))
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def body(self, value: Any, content_type: str) -> Any:
|
|
89
|
+
if value is None:
|
|
90
|
+
return None
|
|
91
|
+
if "multipart/" in content_type.lower():
|
|
92
|
+
return "[Multipart body omitted]"
|
|
93
|
+
if isinstance(value, bytes):
|
|
94
|
+
if not any(
|
|
95
|
+
v in content_type.lower() for v in ("json", "text/", "xml", "form", "javascript")
|
|
96
|
+
):
|
|
97
|
+
return f"[Binary body: {len(value)} bytes]"
|
|
98
|
+
value = value.decode("utf-8", errors="replace")
|
|
99
|
+
if not isinstance(value, str):
|
|
100
|
+
return self.clean(value)
|
|
101
|
+
if "application/x-www-form-urlencoded" in content_type.lower():
|
|
102
|
+
# Preserve repeated field names and empty values.
|
|
103
|
+
pairs = parse_qsl(value, keep_blank_values=True)
|
|
104
|
+
for k, v in pairs:
|
|
105
|
+
if k.lower() in self.fields:
|
|
106
|
+
self.remember(v)
|
|
107
|
+
return [
|
|
108
|
+
{"name": k, "value": MASK if k.lower() in self.fields else self.text(v)}
|
|
109
|
+
for k, v in pairs
|
|
110
|
+
]
|
|
111
|
+
try:
|
|
112
|
+
return self.clean(json.loads(value))
|
|
113
|
+
except (ValueError, TypeError):
|
|
114
|
+
return self.text(value)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Static Rich rendering suitable for native terminals and redirected CI logs."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
5
|
+
|
|
6
|
+
from rich.console import Group, RenderableType
|
|
7
|
+
from rich.json import JSON
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .models import Exchange
|
|
13
|
+
from .protection import Protector
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def body(value: Any, protector: Protector) -> RenderableType:
|
|
17
|
+
value = protector.clean(value)
|
|
18
|
+
if value is None or value == "":
|
|
19
|
+
return Text("Body is empty", style="dim")
|
|
20
|
+
if isinstance(value, (dict, list)):
|
|
21
|
+
return JSON.from_data(value, indent=2, ensure_ascii=False)
|
|
22
|
+
return Text(str(value))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def exchange(record: Exchange, full: bool, protector: Protector) -> RenderableType:
|
|
26
|
+
title = protector.text(record.name)
|
|
27
|
+
status = "No response" if record.status_code is None else f"HTTP {record.status_code}"
|
|
28
|
+
color = (
|
|
29
|
+
"red"
|
|
30
|
+
if record.error
|
|
31
|
+
else ("green" if record.status_code and 200 <= record.status_code < 300 else "yellow")
|
|
32
|
+
)
|
|
33
|
+
facts = Text()
|
|
34
|
+
facts.append(record.method + " ", style="bold cyan")
|
|
35
|
+
facts.append(protector.text(record.url))
|
|
36
|
+
facts.append("\n" + status, style=color)
|
|
37
|
+
if record.duration_ms is not None:
|
|
38
|
+
facts.append(f" · {record.duration_ms:.2f} ms")
|
|
39
|
+
items: list[RenderableType] = [facts]
|
|
40
|
+
if record.error:
|
|
41
|
+
items.append(Text(protector.text(record.error), style="red"))
|
|
42
|
+
if full:
|
|
43
|
+
pairs = parse_qsl(urlsplit(record.url).query, keep_blank_values=True)
|
|
44
|
+
if pairs:
|
|
45
|
+
items += [
|
|
46
|
+
Text("Params", style="bold"),
|
|
47
|
+
JSON.from_data(
|
|
48
|
+
protector.clean([{"name": k, "value": v} for k, v in pairs]), ensure_ascii=False
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
items += [
|
|
52
|
+
Text("Request headers", style="bold"),
|
|
53
|
+
JSON.from_data(protector.clean(record.request_headers), ensure_ascii=False),
|
|
54
|
+
Text("Request body", style="bold"),
|
|
55
|
+
body(record.request_body, protector),
|
|
56
|
+
]
|
|
57
|
+
if record.status_code is not None:
|
|
58
|
+
items += [
|
|
59
|
+
Text("Response headers", style="bold"),
|
|
60
|
+
JSON.from_data(protector.clean(record.response_headers), ensure_ascii=False),
|
|
61
|
+
Text("Response body", style="bold"),
|
|
62
|
+
body(record.response_body, protector),
|
|
63
|
+
]
|
|
64
|
+
if record.assertions:
|
|
65
|
+
table = Table("Assertion", "Result", "Message", box=None, expand=True)
|
|
66
|
+
for a in record.assertions:
|
|
67
|
+
table.add_row(
|
|
68
|
+
Text(protector.text(a.label)),
|
|
69
|
+
Text(a.status, style="green" if a.status == "PASS" else "red"),
|
|
70
|
+
Text(protector.text(a.message)),
|
|
71
|
+
)
|
|
72
|
+
items.append(table)
|
|
73
|
+
return Panel(Group(*items), title=Text(title), border_style="red" if record.failed else "cyan")
|