cloudru-qa-http-client 1.0.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,275 @@
1
+ import codecs
2
+ import json
3
+ from collections.abc import Mapping, Sequence
4
+ from contextlib import suppress
5
+ from typing import cast
6
+ from uuid import uuid4
7
+
8
+ from glom import GlomError
9
+ from httpx import Request, Response
10
+
11
+ from http_client.config import LOGGING_CONFIG as CFG
12
+
13
+ from .sanitizer import sanitize
14
+
15
+ ELLIPSIS = "..."
16
+ DEFAULT_ENCODING = "utf-8"
17
+
18
+ __all__ = [
19
+ "create_request_log",
20
+ "create_response_log",
21
+ "create_response_log_async",
22
+ "decode",
23
+ "is_buffered",
24
+ "is_streamed",
25
+ "parse_error",
26
+ "to_json",
27
+ "trim",
28
+ ]
29
+
30
+
31
+ def create_request_log(
32
+ r: Request,
33
+ sanitize_keys: Sequence[str],
34
+ json_indent: int | None = None,
35
+ *,
36
+ use_line_breaks: bool = True,
37
+ ) -> str:
38
+ """Build an HTTP request log.
39
+
40
+ :param r: Request containing data to log
41
+ :param sanitize_keys: Keys in [glom](https://glom.readthedocs.io/en/latest/) format,
42
+ to mask in request data
43
+ :param json_indent: JSON indentation width (no indentation by default)
44
+ :param use_line_breaks: Whether to add line breaks between request properties
45
+ :returns: Request log
46
+ """
47
+ # NOTE: trace_id here only correlates the request and response.
48
+ # It is unrelated to the trace_id of a server with telemetry enabled.
49
+ trace_id = str(uuid4())
50
+ extensions = cast("dict[str, object]", r.extensions)
51
+ extensions["trace_id"] = trace_id
52
+
53
+ log_parts = [
54
+ f"Request: id={trace_id}",
55
+ f"Method: [{r.method.upper()}] {r.url.scheme}://{r.url.host}{r.url.path}",
56
+ ]
57
+
58
+ _add_query_params(r, log_parts, sanitize_keys, json_indent)
59
+ _add_headers(r, log_parts, sanitize_keys, json_indent)
60
+ _add_body(r, log_parts, sanitize_keys, json_indent, streamed=not is_buffered(r))
61
+
62
+ sep = _sep(use_line_breaks=use_line_breaks)
63
+ return sep.join(log_parts)
64
+
65
+
66
+ def create_response_log(
67
+ r: Response,
68
+ sanitize_keys: Sequence[str],
69
+ json_indent: int | None = None,
70
+ *,
71
+ use_line_breaks: bool = True,
72
+ ) -> str:
73
+ """Build an HTTP response log.
74
+
75
+ :param r: Response containing data to log
76
+ :param sanitize_keys: Keys in [glom](https://glom.readthedocs.io/en/latest/) format,
77
+ to mask in response data
78
+ :param json_indent: JSON indentation width (no indentation by default)
79
+ :param use_line_breaks: Whether to add line breaks between request properties
80
+ :returns: Response log
81
+ """
82
+ extensions = cast("dict[str, object]", r.request.extensions)
83
+ trace_id = extensions.get("trace_id", "<none>")
84
+
85
+ log_parts = [f"Response: id={trace_id}", f"Status: {r.status_code} [{r.reason_phrase}]"]
86
+
87
+ _add_headers(r, log_parts, sanitize_keys, json_indent)
88
+ _add_body(r, log_parts, sanitize_keys, json_indent, streamed=is_streamed(r))
89
+
90
+ sep = _sep(use_line_breaks=use_line_breaks)
91
+ return sep.join(log_parts)
92
+
93
+
94
+ async def create_response_log_async(
95
+ r: Response,
96
+ sanitize_keys: Sequence[str],
97
+ json_indent: int | None = None,
98
+ *,
99
+ use_line_breaks: bool = True,
100
+ ) -> str:
101
+ """Build an asynchronous client response log.
102
+
103
+ The httpx response hook runs before the client finishes reading the body, so
104
+ an asynchronous response still has an asynchronous stream that cannot use synchronous read().
105
+
106
+ :returns: Response log
107
+ """
108
+ await _aread_buffered(r)
109
+ return create_response_log(r, sanitize_keys, json_indent, use_line_breaks=use_line_breaks)
110
+
111
+
112
+ async def _aread_buffered(r: Response) -> None:
113
+ """Finish reading a non-streaming response body."""
114
+ if is_streamed(r):
115
+ return
116
+
117
+ # Logging must not fail the request: _add_body handles unreadable bodies.
118
+ with suppress(Exception):
119
+ await r.aread()
120
+
121
+
122
+ def _add_query_params(
123
+ r: Request,
124
+ log_parts: list[str],
125
+ sanitize_keys: Sequence[str],
126
+ json_indent: int | None,
127
+ ) -> None:
128
+ """Add query parameters to the log."""
129
+ if not r.url.query:
130
+ return
131
+
132
+ try:
133
+ sanitized_params = sanitize(dict(r.url.params.items()), sanitize_keys)
134
+ log_parts.append(f"QueryParams: {to_json(sanitized_params, json_indent)}")
135
+ except (TypeError, ValueError, GlomError) as e:
136
+ log_parts.append(f"QueryParams: {parse_error(e)}")
137
+
138
+
139
+ def _add_headers(
140
+ r: Request | Response,
141
+ log_parts: list[str],
142
+ sanitize_keys: Sequence[str],
143
+ json_indent: int | None,
144
+ ) -> None:
145
+ """Add HTTP headers to the log."""
146
+ try:
147
+ sanitized_headers = sanitize(dict(r.headers.items()), sanitize_keys)
148
+ log_parts.append(f"Headers: {to_json(sanitized_headers, json_indent)}")
149
+ except (TypeError, ValueError, GlomError) as e:
150
+ log_parts.append(f"Headers: {parse_error(e)}")
151
+
152
+
153
+ def _add_body(
154
+ r: Request | Response,
155
+ log_parts: list[str],
156
+ sanitize_keys: Sequence[str],
157
+ json_indent: int | None,
158
+ *,
159
+ streamed: bool,
160
+ ) -> None:
161
+ """Add the request or response body to the log."""
162
+ if streamed:
163
+ log_parts.append("Body: <streamed content>")
164
+ return
165
+
166
+ headers: Mapping[str, str] = r.headers
167
+ content_type = headers.get("Content-Type", "").lower()
168
+ try:
169
+ if content_type.startswith("application/json"):
170
+ decoded_body = cast("object", json.loads(r.read()))
171
+ sanitized_body = sanitize(decoded_body, sanitize_keys)
172
+ log_parts.append(f"Body: {trim(to_json(sanitized_body, json_indent), CFG.json_log_maxlength)}")
173
+ elif content_type.startswith("text/plain"):
174
+ log_parts.append(f"Body: {trim(decode(r), CFG.plain_text_log_maxlength)}")
175
+ elif content_type.startswith("application/octet-stream"):
176
+ log_parts.append(f"Body: {_octet_stream(r.read())}")
177
+ elif content_type:
178
+ log_parts.append(f"Body: <not logged, content-type: {content_type}>")
179
+ except (TypeError, ValueError, GlomError, RuntimeError, OSError) as e:
180
+ log_parts.append(f"Body: {parse_error(e)}")
181
+
182
+
183
+ def _octet_stream(content: bytes) -> str:
184
+ try:
185
+ return trim(content.decode(), CFG.octet_stream_log_maxlength)
186
+ except UnicodeDecodeError:
187
+ return f"<binary content ({len(content)} bytes)>"
188
+
189
+
190
+ def is_streamed(r: Response) -> bool:
191
+ """Check whether a response is streaming and unread."""
192
+ extensions = cast("dict[str, object]", r.request.extensions)
193
+ return bool(extensions.get("is_streaming", False)) and not r.is_stream_consumed
194
+
195
+
196
+ def is_buffered(r: Request) -> bool:
197
+ """Check whether the request body is in memory.
198
+
199
+ Reading a streaming request body would consume the stream before sending it.
200
+ """
201
+ return hasattr(r, "_content")
202
+
203
+
204
+ def decode(r: Request | Response) -> str:
205
+ """Decode the body using the charset from Content-Type."""
206
+ content = r.read()
207
+ headers: Mapping[str, str] = r.headers
208
+ return content.decode(_charset(headers.get("Content-Type", "")), errors="replace")
209
+
210
+
211
+ def _charset(content_type: str) -> str:
212
+ """Extract the encoding from Content-Type.
213
+
214
+ A header may have multiple parameters (`text/plain; charset=utf-8; format=flowed`),
215
+ so select charset by its parameter name rather than taking everything after `charset=`.
216
+ Replace unknown encodings with utf-8 to avoid hiding the body.
217
+ """
218
+ for parameter in content_type.split(";")[1:]:
219
+ name, _, value = parameter.partition("=")
220
+ if name.strip().lower() != "charset":
221
+ continue
222
+
223
+ charset = value.strip().strip("\"'")
224
+ if not charset:
225
+ break
226
+
227
+ try:
228
+ codecs.lookup(charset)
229
+ except LookupError:
230
+ break
231
+
232
+ return charset
233
+
234
+ return DEFAULT_ENCODING
235
+
236
+
237
+ def parse_error(e: Exception) -> str:
238
+ """Build a safe description of a parsing error.
239
+
240
+ Do not include exception text: it may contain body fragments or URLs with secrets.
241
+ """
242
+ return f"<Error parse: {type(e).__name__}>"
243
+
244
+
245
+ def to_json(value: object, indent: int | None) -> str:
246
+ """Serialize a value to JSON while preserving Unicode.
247
+
248
+ :param value: Value to serialize
249
+ :param indent: Indentation width
250
+ :returns: JSON representation
251
+ """
252
+ return json.dumps(value, indent=indent, ensure_ascii=False)
253
+
254
+
255
+ def trim(text: str, length: int | None) -> str:
256
+ """Truncate a string while preserving its beginning and end.
257
+
258
+ :param text: Original string
259
+ :param length: Length limit; None and nonpositive numbers disable it
260
+ :returns: Original or truncated string
261
+ """
262
+ if not length or length < 0 or len(text) <= length:
263
+ return text
264
+
265
+ if length <= len(ELLIPSIS):
266
+ return ELLIPSIS[:length]
267
+
268
+ payload = length - len(ELLIPSIS)
269
+ tail = payload // 2
270
+ head = payload - tail
271
+ return text[:head] + ELLIPSIS + (text[-tail:] if tail else "")
272
+
273
+
274
+ def _sep(*, use_line_breaks: bool) -> str:
275
+ return "\n" if use_line_breaks else " "
@@ -0,0 +1,59 @@
1
+ from collections.abc import Sequence
2
+ from copy import deepcopy
3
+ from typing import cast
4
+
5
+ import glom
6
+
7
+
8
+ def sanitize[T](
9
+ value_to_sanitize: T,
10
+ sensitive_keys: Sequence[str],
11
+ ) -> T:
12
+ """Create a new dictionary with sensitive data masked.
13
+
14
+ :param value_to_sanitize: Dictionary or list to process.
15
+ :param sensitive_keys: Names of fields to mask at any depth, or exact glom paths.
16
+ :returns: New dictionary or list with sensitive data masked.
17
+
18
+ #### Examples:
19
+ >>> result = sanitize({"d": {"password": "123123"}, "v": 123123}, ["d.password"])
20
+ >>> assert result == {'d': {'password': '****'}, "v": 123123}
21
+
22
+ >>> result = sanitize([{"d": {"passwd": "123123"}, "v": 123123}], ["*.d.passwd"])
23
+ >>> assert result == [{'d': {'passwd': '****'}, "v": 123123}]
24
+
25
+ >>> result = sanitize({"d": [{"passwd": "123123"}, {"passwd": "123123"}]}, ["d.*.passwd"])
26
+ >>> assert result == {"d": [{"passwd": "****"}, {"passwd": "****"}]}
27
+ """
28
+ if len(sensitive_keys) == 0:
29
+ return value_to_sanitize
30
+
31
+ value = deepcopy(value_to_sanitize)
32
+ names = frozenset(key for key in sensitive_keys if not any(character in key for character in ".*[()"))
33
+ _mask_nested_fields(value, names)
34
+
35
+ for sensitive_key in sensitive_keys:
36
+ if glom.glom(value, sensitive_key, default=None) is None:
37
+ continue
38
+
39
+ glom.assign(value, sensitive_key, "****")
40
+
41
+ return value
42
+
43
+
44
+ def _mask_nested_fields(value: object, names: frozenset[str]) -> None:
45
+ pending = [value]
46
+ visited: set[int] = set()
47
+ while pending:
48
+ current = pending.pop()
49
+ if id(current) in visited:
50
+ continue
51
+ visited.add(id(current))
52
+ if isinstance(current, dict):
53
+ mapping = cast("dict[object, object]", current)
54
+ for key in mapping:
55
+ if key in names:
56
+ mapping[key] = "****"
57
+ pending.extend(mapping.values())
58
+ elif isinstance(current, list):
59
+ pending.extend(cast("list[object]", current))
@@ -0,0 +1,12 @@
1
+ import binascii
2
+ import os
3
+
4
+ TRACEPARENT_VERSION = "00"
5
+ TRACEPARENT_FLAGS = "01"
6
+
7
+
8
+ def generate() -> str:
9
+ """Generate traceparent."""
10
+ trace_id = binascii.hexlify(os.urandom(16)).decode()
11
+ random_id = binascii.hexlify(os.urandom(8)).decode()
12
+ return f"{TRACEPARENT_VERSION}-{trace_id}-{random_id}-{TRACEPARENT_FLAGS}"