robotframework-request-logger 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.
RequestLogger.py ADDED
@@ -0,0 +1,5 @@
1
+ """Public Robot Framework library import."""
2
+
3
+ from request_logger import RequestLogger
4
+
5
+ __all__ = ["RequestLogger"]
@@ -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")
@@ -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,9 @@
1
+ RequestLogger.py,sha256=JaMAoAZM3HT9qGdFRPb5YxJ2j6ssNBb6bQoBW7n4_no,116
2
+ request_logger/__init__.py,sha256=y70hoHS7sZvpiIyOvSF0XZVavZBI3_peSPya--CNyLc,9662
3
+ request_logger/models.py,sha256=Zrxh2MhuPHuti0S7YSGdryaPrR5RpqFXBuEtA594xn0,849
4
+ request_logger/protection.py,sha256=VkC1LHycpBrXydYq0q7lVro1LZDdQ_8Lvic4xTsOB2o,4272
5
+ request_logger/render.py,sha256=c4dkDic_TADJWOdG6rpma1tRxvbNGXNZv8z36WreHs0,2896
6
+ robotframework_request_logger-0.1.0.dist-info/METADATA,sha256=l66ystOXxd70V6OyDUttEk1nAKh4nOVI-sc0INtyBaI,3053
7
+ robotframework_request_logger-0.1.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
8
+ robotframework_request_logger-0.1.0.dist-info/licenses/LICENSE,sha256=NV9lHnTZ3T1Vvk2HjpxRWhWwXbq19hahbsQw1DZAT3E,1069
9
+ robotframework_request_logger-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.