python-wrap 0.2.1__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.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/logs.py
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""Logging decorators: argument/return logging, audit records and call-stack tracing.
|
|
2
|
+
|
|
3
|
+
All of them log through `python_wrap._core.get_logger`, never configure logging,
|
|
4
|
+
and support plain functions, ``async def`` functions and methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextvars
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import reprlib
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from types import FrameType, MappingProxyType, TracebackType
|
|
21
|
+
from typing import Any, TypeVar, cast, overload
|
|
22
|
+
|
|
23
|
+
from python_wrap import _core, config
|
|
24
|
+
from python_wrap._core import F, LoggerLike, P, R
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DEFAULT_REDACT",
|
|
28
|
+
"AuditRecord",
|
|
29
|
+
"audit",
|
|
30
|
+
"current_log_context",
|
|
31
|
+
"log_args",
|
|
32
|
+
"log_context",
|
|
33
|
+
"log_return",
|
|
34
|
+
"trace",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
S = TypeVar("S")
|
|
38
|
+
|
|
39
|
+
DEFAULT_REDACT: tuple[str, ...] = (
|
|
40
|
+
"password",
|
|
41
|
+
"passwd",
|
|
42
|
+
"secret",
|
|
43
|
+
"token",
|
|
44
|
+
"api_key",
|
|
45
|
+
"apikey",
|
|
46
|
+
"authorization",
|
|
47
|
+
"auth",
|
|
48
|
+
"credential",
|
|
49
|
+
"private_key",
|
|
50
|
+
"cookie",
|
|
51
|
+
"session",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_MASK = "***"
|
|
55
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------- helpers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_repr(value: object) -> str:
|
|
62
|
+
"""``repr(value)`` that never raises."""
|
|
63
|
+
try:
|
|
64
|
+
return repr(value)
|
|
65
|
+
except BaseException:
|
|
66
|
+
return f"<unrepresentable {type(value).__name__}>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _instrument(
|
|
70
|
+
func: Callable[P, R],
|
|
71
|
+
start: Callable[[tuple[Any, ...], dict[str, Any]], S],
|
|
72
|
+
finish: Callable[[S, bool, Any], None],
|
|
73
|
+
) -> Callable[P, R]:
|
|
74
|
+
"""Wrap ``func`` (sync or async) with a start hook and a finish hook.
|
|
75
|
+
|
|
76
|
+
``start(args, kwargs)`` runs before the call and returns a state object.
|
|
77
|
+
``finish(state, ok, value)`` runs afterwards with the return value (``ok=True``) or the
|
|
78
|
+
raised exception (``ok=False``); the exception is then re-raised. For ``async def``
|
|
79
|
+
functions the hooks surround the awaited coroutine. A failing ``finish`` is logged and
|
|
80
|
+
swallowed: it never replaces the function's result or exception.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def run_finish(state: S, ok: bool, value: Any) -> None:
|
|
84
|
+
try:
|
|
85
|
+
finish(state, ok, value)
|
|
86
|
+
except Exception:
|
|
87
|
+
_core.get_logger(None).exception("%s: logging hook failed", _core.qualname(func))
|
|
88
|
+
|
|
89
|
+
if _core.is_async(func):
|
|
90
|
+
|
|
91
|
+
@functools.wraps(func)
|
|
92
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
93
|
+
state = start(args, kwargs)
|
|
94
|
+
try:
|
|
95
|
+
result = await func(*args, **kwargs) # type: ignore[misc]
|
|
96
|
+
except BaseException as exc:
|
|
97
|
+
run_finish(state, False, exc)
|
|
98
|
+
raise
|
|
99
|
+
run_finish(state, True, result)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
return cast("Callable[P, R]", awrapper)
|
|
103
|
+
|
|
104
|
+
@functools.wraps(func)
|
|
105
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
106
|
+
state = start(args, kwargs)
|
|
107
|
+
try:
|
|
108
|
+
result = func(*args, **kwargs)
|
|
109
|
+
except BaseException as exc:
|
|
110
|
+
run_finish(state, False, exc)
|
|
111
|
+
raise
|
|
112
|
+
run_finish(state, True, result)
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
return cast("Callable[P, R]", wrapper)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _redact_predicate(redact: Iterable[str] | None) -> Callable[[str], bool]:
|
|
119
|
+
"""Build a case-insensitive "does this name look sensitive" test."""
|
|
120
|
+
needles = tuple(n.lower() for n in (DEFAULT_REDACT if redact is None else redact) if n)
|
|
121
|
+
if not needles:
|
|
122
|
+
return lambda name: False
|
|
123
|
+
return lambda name: any(n in name.lower() for n in needles)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _short(value: object, max_length: int) -> str:
|
|
127
|
+
"""``repr`` with container truncation and a hard character limit."""
|
|
128
|
+
r = reprlib.Repr()
|
|
129
|
+
r.maxstring = r.maxother = max_length
|
|
130
|
+
r.maxlist = r.maxtuple = r.maxset = r.maxdict = r.maxdeque = r.maxarray = 6
|
|
131
|
+
text = r.repr(value) # reprlib already survives a failing __repr__
|
|
132
|
+
if max_length >= 0 and len(text) > max_length:
|
|
133
|
+
text = text[: max(max_length - 3, 0)] + "..."
|
|
134
|
+
return text
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _ArgBinder:
|
|
138
|
+
"""Maps a call's arguments to parameter names, masking sensitive ones.
|
|
139
|
+
|
|
140
|
+
The signature is analysed once. A call that passes plain positional parameters only
|
|
141
|
+
(no keywords, no ``*args`` / ``**kwargs`` in the signature, no excess arguments) is
|
|
142
|
+
named straight from a precomputed name tuple; everything else goes through
|
|
143
|
+
``Signature.bind``. Redaction is decided once per parameter name.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, sig: inspect.Signature | None, is_secret: Callable[[str], bool]) -> None:
|
|
147
|
+
self.sig = sig
|
|
148
|
+
self.is_secret = is_secret
|
|
149
|
+
self.names: tuple[str, ...] = ()
|
|
150
|
+
self.flags: tuple[bool, ...] = ()
|
|
151
|
+
self.secret_names: dict[str, bool] = {}
|
|
152
|
+
self.fast_max = -1
|
|
153
|
+
if sig is not None:
|
|
154
|
+
params = list(sig.parameters.values())
|
|
155
|
+
self.secret_names = {p.name: is_secret(p.name) for p in params}
|
|
156
|
+
plain = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
157
|
+
if all(p.kind in plain for p in params):
|
|
158
|
+
self.names = tuple(p.name for p in params)
|
|
159
|
+
self.flags = tuple(self.secret_names[n] for n in self.names)
|
|
160
|
+
self.fast_max = len(params)
|
|
161
|
+
|
|
162
|
+
def _secret(self, name: str) -> bool:
|
|
163
|
+
known = self.secret_names.get(name)
|
|
164
|
+
return self.is_secret(name) if known is None else known
|
|
165
|
+
|
|
166
|
+
def __call__(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
if not kwargs and len(args) <= self.fast_max:
|
|
168
|
+
return {
|
|
169
|
+
n: (_MASK if secret else v)
|
|
170
|
+
for n, secret, v in zip(self.names, self.flags, args, strict=False)
|
|
171
|
+
}
|
|
172
|
+
sig = self.sig
|
|
173
|
+
bound = None
|
|
174
|
+
if sig is not None:
|
|
175
|
+
try:
|
|
176
|
+
bound = sig.bind(*args, **kwargs)
|
|
177
|
+
except TypeError:
|
|
178
|
+
bound = None
|
|
179
|
+
named: dict[str, Any] = {}
|
|
180
|
+
if sig is None or bound is None: # the call is invalid anyway; show what we were given
|
|
181
|
+
named = {f"arg{i}": v for i, v in enumerate(args)}
|
|
182
|
+
named.update(kwargs)
|
|
183
|
+
else:
|
|
184
|
+
for name, value in bound.arguments.items():
|
|
185
|
+
if sig.parameters[name].kind is inspect.Parameter.VAR_KEYWORD:
|
|
186
|
+
named.update(value)
|
|
187
|
+
else:
|
|
188
|
+
named[name] = value
|
|
189
|
+
return {k: (_MASK if self._secret(k) else v) for k, v in named.items()}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _signature(func: Callable[..., Any]) -> inspect.Signature | None:
|
|
193
|
+
try:
|
|
194
|
+
return inspect.signature(func)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _format_call(name: str, arguments: Mapping[str, Any], max_length: int) -> str:
|
|
200
|
+
parts = [
|
|
201
|
+
f"{k}={_short(v, max_length) if v is not _MASK else repr(_MASK)}"
|
|
202
|
+
for k, v in arguments.items()
|
|
203
|
+
]
|
|
204
|
+
return f"{name}({', '.join(parts)})"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_internal_files: dict[str, bool] = {}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_internal(filename: str) -> bool:
|
|
211
|
+
try:
|
|
212
|
+
return _internal_files[filename]
|
|
213
|
+
except KeyError:
|
|
214
|
+
result = os.path.dirname(os.path.abspath(filename)) == _PACKAGE_DIR
|
|
215
|
+
if len(_internal_files) < 4096: # co_filename values are few; stay bounded anyway
|
|
216
|
+
_internal_files[filename] = result
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _external_frame() -> FrameType | None:
|
|
221
|
+
"""The innermost frame of the current stack that is outside python_wrap."""
|
|
222
|
+
frame: FrameType | None = sys._getframe(1)
|
|
223
|
+
while frame is not None and _is_internal(frame.f_code.co_filename):
|
|
224
|
+
frame = frame.f_back
|
|
225
|
+
return frame
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _caller_location() -> str:
|
|
229
|
+
frame = _external_frame()
|
|
230
|
+
if frame is None:
|
|
231
|
+
return "<unknown>" # pragma: no cover
|
|
232
|
+
return f"{frame.f_code.co_filename}:{frame.f_lineno}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ----------------------------------------------------------------------- log context
|
|
236
|
+
|
|
237
|
+
_log_fields: contextvars.ContextVar[Mapping[str, Any]] = contextvars.ContextVar(
|
|
238
|
+
"python_wrap_log_fields", default=MappingProxyType({})
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def current_log_context() -> dict[str, Any]:
|
|
243
|
+
"""Return a copy of the fields bound by the enclosing `log_context` blocks.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
A new dict (empty when no `log_context` is active); changing it has no effect.
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
>>> with log_context(request_id="r1"):
|
|
250
|
+
... current_log_context()
|
|
251
|
+
{'request_id': 'r1'}
|
|
252
|
+
"""
|
|
253
|
+
return dict(_log_fields.get())
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _safe_str(value: object) -> str:
|
|
257
|
+
try:
|
|
258
|
+
return str(value)
|
|
259
|
+
except BaseException:
|
|
260
|
+
return f"<unprintable {type(value).__name__}>"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _log(
|
|
264
|
+
lg: logging.Logger,
|
|
265
|
+
level: int,
|
|
266
|
+
msg: str,
|
|
267
|
+
*args: Any,
|
|
268
|
+
context: Mapping[str, Any] | None = None,
|
|
269
|
+
suffix: bool = True,
|
|
270
|
+
extra: dict[str, Any] | None = None,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""``lg.log`` plus the bound context: ``extra["context"]`` and a ``[k=v]`` suffix."""
|
|
273
|
+
fields = _log_fields.get() if context is None else context
|
|
274
|
+
if fields:
|
|
275
|
+
extra = {**(extra or {}), "context": dict(fields)}
|
|
276
|
+
if suffix:
|
|
277
|
+
text = " ".join(f"{k}={_safe_str(v)}" for k, v in fields.items())
|
|
278
|
+
msg = f"{msg} [{text.replace('%', '%%')}]"
|
|
279
|
+
lg.log(level, msg, *args, extra=extra)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class _LogContext:
|
|
283
|
+
"""Context manager (sync and async) and decorator returned by `log_context`."""
|
|
284
|
+
|
|
285
|
+
def __init__(self, fields: dict[str, Any]) -> None:
|
|
286
|
+
self._fields = fields
|
|
287
|
+
self._tokens: list[contextvars.Token[Mapping[str, Any]]] = []
|
|
288
|
+
|
|
289
|
+
def __enter__(self) -> dict[str, Any]:
|
|
290
|
+
merged = {**_log_fields.get(), **self._fields}
|
|
291
|
+
self._tokens.append(_log_fields.set(merged))
|
|
292
|
+
return dict(merged)
|
|
293
|
+
|
|
294
|
+
def __exit__(
|
|
295
|
+
self,
|
|
296
|
+
exc_type: type[BaseException] | None,
|
|
297
|
+
exc: BaseException | None,
|
|
298
|
+
tb: TracebackType | None,
|
|
299
|
+
) -> None:
|
|
300
|
+
_log_fields.reset(self._tokens.pop())
|
|
301
|
+
|
|
302
|
+
async def __aenter__(self) -> dict[str, Any]:
|
|
303
|
+
return self.__enter__()
|
|
304
|
+
|
|
305
|
+
async def __aexit__(
|
|
306
|
+
self,
|
|
307
|
+
exc_type: type[BaseException] | None,
|
|
308
|
+
exc: BaseException | None,
|
|
309
|
+
tb: TracebackType | None,
|
|
310
|
+
) -> None:
|
|
311
|
+
self.__exit__(exc_type, exc, tb)
|
|
312
|
+
|
|
313
|
+
def __call__(self, func: F) -> F:
|
|
314
|
+
fields = self._fields
|
|
315
|
+
if _core.is_async(func):
|
|
316
|
+
|
|
317
|
+
@functools.wraps(func)
|
|
318
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
319
|
+
async with _LogContext(fields):
|
|
320
|
+
return await func(*args, **kwargs)
|
|
321
|
+
|
|
322
|
+
return cast("F", awrapper)
|
|
323
|
+
|
|
324
|
+
@functools.wraps(func)
|
|
325
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
326
|
+
with _LogContext(fields):
|
|
327
|
+
return func(*args, **kwargs)
|
|
328
|
+
|
|
329
|
+
return cast("F", wrapper)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def log_context(**fields: Any) -> _LogContext:
|
|
333
|
+
"""Bind extra fields to every python-wrap log line made inside a block or call.
|
|
334
|
+
|
|
335
|
+
Works as a context manager (``with`` and ``async with``) and as a decorator for plain
|
|
336
|
+
and ``async def`` functions. Blocks nest: inner fields add to, and override, outer ones.
|
|
337
|
+
The fields live in a `contextvars.ContextVar`, so each thread and asyncio task sees its
|
|
338
|
+
own; a new thread starts empty unless you run it in a copied context
|
|
339
|
+
(``contextvars.copy_context().run(...)``; `run_in_thread` does this for you), or
|
|
340
|
+
unless ``sys.flags.thread_inherit_context`` is set (the default on free-threaded
|
|
341
|
+
builds).
|
|
342
|
+
|
|
343
|
+
`log_args`, `log_return`, `audit`, `trace` and `timing` append `` [key=value ...]`` to
|
|
344
|
+
their message and attach the dict as ``LogRecord.context``; `AuditRecord.context`
|
|
345
|
+
holds it too.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
**fields: The fields to bind, e.g. ``request_id="abc"``.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
An object usable as ``with log_context(...) as fields``, ``async with`` or ``@``.
|
|
352
|
+
The ``as`` target is the merged dict of fields.
|
|
353
|
+
|
|
354
|
+
Example:
|
|
355
|
+
>>> @log_return(logger="app")
|
|
356
|
+
... def handle(): ...
|
|
357
|
+
>>> with log_context(request_id="r1"):
|
|
358
|
+
... handle()
|
|
359
|
+
"""
|
|
360
|
+
return _LogContext(dict(fields))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------- log_args
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@overload
|
|
367
|
+
def log_args(func: F, /) -> F: ...
|
|
368
|
+
@overload
|
|
369
|
+
def log_args(
|
|
370
|
+
func: None = None,
|
|
371
|
+
/,
|
|
372
|
+
*,
|
|
373
|
+
logger: LoggerLike = None,
|
|
374
|
+
level: int = logging.DEBUG,
|
|
375
|
+
redact: Iterable[str] | None = None,
|
|
376
|
+
redact_extra: Iterable[str] = (),
|
|
377
|
+
max_length: int = 200,
|
|
378
|
+
) -> Callable[[F], F]: ...
|
|
379
|
+
def log_args(
|
|
380
|
+
func: Callable[..., Any] | None = None,
|
|
381
|
+
/,
|
|
382
|
+
*,
|
|
383
|
+
logger: LoggerLike = None,
|
|
384
|
+
level: int = logging.DEBUG,
|
|
385
|
+
redact: Iterable[str] | None = None,
|
|
386
|
+
redact_extra: Iterable[str] = (),
|
|
387
|
+
max_length: int = 200,
|
|
388
|
+
) -> Any:
|
|
389
|
+
"""Log each call's arguments, by parameter name, before running it.
|
|
390
|
+
|
|
391
|
+
The message looks like ``Calling f(x=1, password='***')``. Arguments are bound to the
|
|
392
|
+
function's signature so positional values are named too.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
func: The function, when used as a bare ``@log_args``.
|
|
396
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
397
|
+
level: Log level. Defaults to ``DEBUG`` (v0.1 logged at ``INFO``).
|
|
398
|
+
redact: Parameter-name fragments whose values are shown as ``'***'``
|
|
399
|
+
(case-insensitive substring match). ``None`` (default) uses
|
|
400
|
+
`DEFAULT_REDACT` or the list set by `python_wrap.config.configure`. A list
|
|
401
|
+
given here **replaces** that; pass ``()`` to disable redaction.
|
|
402
|
+
redact_extra: Fragments to mask **in addition** to the list above (never
|
|
403
|
+
replaces it). Added to any `redact_extra` set via `configure`.
|
|
404
|
+
max_length: Longest rendering of a single value before it is truncated.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
The decorated function, with its signature preserved.
|
|
408
|
+
|
|
409
|
+
Example:
|
|
410
|
+
>>> @log_args(logger="app")
|
|
411
|
+
... def login(user, password): ...
|
|
412
|
+
"""
|
|
413
|
+
secret = _redact_predicate(config.redaction(redact, DEFAULT_REDACT) + tuple(redact_extra))
|
|
414
|
+
|
|
415
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
416
|
+
lg = _core.get_logger(logger)
|
|
417
|
+
name = _core.qualname(f)
|
|
418
|
+
binder = _ArgBinder(_signature(f), secret)
|
|
419
|
+
|
|
420
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
421
|
+
if lg.isEnabledFor(level):
|
|
422
|
+
bound = binder(args, kwargs)
|
|
423
|
+
_log(lg, level, "Calling %s", _format_call(name, bound, max_length))
|
|
424
|
+
|
|
425
|
+
return _instrument(f, start, lambda state, ok, value: None)
|
|
426
|
+
|
|
427
|
+
return decorate if func is None else decorate(func)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# -------------------------------------------------------------------------- log_return
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@overload
|
|
434
|
+
def log_return(func: F, /) -> F: ...
|
|
435
|
+
@overload
|
|
436
|
+
def log_return(
|
|
437
|
+
func: None = None,
|
|
438
|
+
/,
|
|
439
|
+
*,
|
|
440
|
+
logger: LoggerLike = None,
|
|
441
|
+
level: int = logging.DEBUG,
|
|
442
|
+
max_length: int = 200,
|
|
443
|
+
) -> Callable[[F], F]: ...
|
|
444
|
+
def log_return(
|
|
445
|
+
func: Callable[..., Any] | None = None,
|
|
446
|
+
/,
|
|
447
|
+
*,
|
|
448
|
+
logger: LoggerLike = None,
|
|
449
|
+
level: int = logging.DEBUG,
|
|
450
|
+
max_length: int = 200,
|
|
451
|
+
) -> Any:
|
|
452
|
+
"""Log the value a function returns, or the exception type it raises.
|
|
453
|
+
|
|
454
|
+
Logs ``f returned <value>``; if the function raises, logs ``f raised ValueError`` and
|
|
455
|
+
re-raises the exception unchanged.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
func: The function, when used as a bare ``@log_return``.
|
|
459
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
460
|
+
level: Log level. Defaults to ``DEBUG``.
|
|
461
|
+
max_length: Longest rendering of the return value before it is truncated.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
The decorated function, with its signature preserved.
|
|
465
|
+
|
|
466
|
+
Example:
|
|
467
|
+
>>> @log_return
|
|
468
|
+
... def add(a, b):
|
|
469
|
+
... return a + b
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
473
|
+
lg = _core.get_logger(logger)
|
|
474
|
+
name = _core.qualname(f)
|
|
475
|
+
|
|
476
|
+
def finish(state: None, ok: bool, value: Any) -> None:
|
|
477
|
+
if not lg.isEnabledFor(level):
|
|
478
|
+
return
|
|
479
|
+
if ok:
|
|
480
|
+
_log(lg, level, "%s returned %s", name, _short(value, max_length))
|
|
481
|
+
else:
|
|
482
|
+
_log(lg, level, "%s raised %s", name, type(value).__name__)
|
|
483
|
+
|
|
484
|
+
return _instrument(f, lambda args, kwargs: None, finish)
|
|
485
|
+
|
|
486
|
+
return decorate if func is None else decorate(func)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ------------------------------------------------------------------------------ audit
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@dataclass(frozen=True)
|
|
493
|
+
class AuditRecord:
|
|
494
|
+
"""One audited call.
|
|
495
|
+
|
|
496
|
+
Attributes:
|
|
497
|
+
function: Qualified name of the audited function.
|
|
498
|
+
timestamp: When the call started (timezone-aware, UTC).
|
|
499
|
+
caller: ``"file:line"`` of the code that called the function.
|
|
500
|
+
arguments: Parameter name to value; sensitive parameters hold ``'***'``.
|
|
501
|
+
status: ``"success"`` or ``"error"``.
|
|
502
|
+
error: ``repr`` of the exception, or ``None``.
|
|
503
|
+
duration: Seconds the call took.
|
|
504
|
+
result: The return value, only when ``include_result=True`` (else ``None``).
|
|
505
|
+
context: Fields bound by `log_context` when the call started (empty if none).
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
function: str
|
|
509
|
+
timestamp: datetime
|
|
510
|
+
caller: str
|
|
511
|
+
arguments: dict[str, Any]
|
|
512
|
+
status: str
|
|
513
|
+
error: str | None
|
|
514
|
+
duration: float
|
|
515
|
+
result: Any = None
|
|
516
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@overload
|
|
520
|
+
def audit(func: F, /) -> F: ...
|
|
521
|
+
@overload
|
|
522
|
+
def audit(
|
|
523
|
+
func: None = None,
|
|
524
|
+
/,
|
|
525
|
+
*,
|
|
526
|
+
logger: LoggerLike = None,
|
|
527
|
+
level: int = logging.INFO,
|
|
528
|
+
redact: Iterable[str] | None = None,
|
|
529
|
+
redact_extra: Iterable[str] = (),
|
|
530
|
+
include_result: bool = False,
|
|
531
|
+
sink: Callable[[AuditRecord], None] | None = None,
|
|
532
|
+
) -> Callable[[F], F]: ...
|
|
533
|
+
def audit(
|
|
534
|
+
func: Callable[..., Any] | None = None,
|
|
535
|
+
/,
|
|
536
|
+
*,
|
|
537
|
+
logger: LoggerLike = None,
|
|
538
|
+
level: int = logging.INFO,
|
|
539
|
+
redact: Iterable[str] | None = None,
|
|
540
|
+
redact_extra: Iterable[str] = (),
|
|
541
|
+
include_result: bool = False,
|
|
542
|
+
sink: Callable[[AuditRecord], None] | None = None,
|
|
543
|
+
) -> Any:
|
|
544
|
+
"""Record who called a function, with what, and how it ended.
|
|
545
|
+
|
|
546
|
+
Every call produces an `AuditRecord`. One log line is emitted with the record
|
|
547
|
+
attached as ``extra={"audit": record}`` (read it from ``LogRecord.audit``), and
|
|
548
|
+
``sink(record)`` is called if given. Exceptions are recorded and re-raised. An
|
|
549
|
+
exception raised by ``sink`` is logged and swallowed.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
func: The function, when used as a bare ``@audit``.
|
|
553
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
554
|
+
level: Log level of the audit line.
|
|
555
|
+
redact: Parameter-name fragments whose values are masked as ``'***'``;
|
|
556
|
+
``None`` (default) uses `DEFAULT_REDACT` or the list set by
|
|
557
|
+
`python_wrap.config.configure`; a list given here replaces it and ``()``
|
|
558
|
+
disables redaction.
|
|
559
|
+
redact_extra: Fragments to mask in addition to the list above (never
|
|
560
|
+
replaces it). Added to any `redact_extra` set via `configure`.
|
|
561
|
+
include_result: Store the return value in ``record.result``.
|
|
562
|
+
sink: Callable receiving every record, e.g. to write to a database.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
The decorated function, with its signature preserved.
|
|
566
|
+
|
|
567
|
+
Example:
|
|
568
|
+
>>> records = []
|
|
569
|
+
>>> @audit(sink=records.append)
|
|
570
|
+
... def delete_user(user_id, token): ...
|
|
571
|
+
"""
|
|
572
|
+
secret = _redact_predicate(config.redaction(redact, DEFAULT_REDACT) + tuple(redact_extra))
|
|
573
|
+
|
|
574
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
575
|
+
lg = _core.get_logger(logger)
|
|
576
|
+
name = _core.qualname(f)
|
|
577
|
+
binder = _ArgBinder(_signature(f), secret)
|
|
578
|
+
|
|
579
|
+
def start(
|
|
580
|
+
args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
581
|
+
) -> tuple[datetime, str, dict[str, Any], float, dict[str, Any]] | None:
|
|
582
|
+
if sink is None and not lg.isEnabledFor(level):
|
|
583
|
+
return None # nobody would see the record: skip building it
|
|
584
|
+
return (
|
|
585
|
+
datetime.now(timezone.utc),
|
|
586
|
+
_caller_location(),
|
|
587
|
+
binder(args, kwargs),
|
|
588
|
+
_core.perf_counter(),
|
|
589
|
+
current_log_context(),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def finish(
|
|
593
|
+
state: tuple[datetime, str, dict[str, Any], float, dict[str, Any]] | None,
|
|
594
|
+
ok: bool,
|
|
595
|
+
value: Any,
|
|
596
|
+
) -> None:
|
|
597
|
+
if state is None:
|
|
598
|
+
return
|
|
599
|
+
timestamp, caller, arguments, began, context = state
|
|
600
|
+
record = AuditRecord(
|
|
601
|
+
function=name,
|
|
602
|
+
timestamp=timestamp,
|
|
603
|
+
caller=caller,
|
|
604
|
+
arguments=arguments,
|
|
605
|
+
status="success" if ok else "error",
|
|
606
|
+
error=None if ok else _safe_repr(value),
|
|
607
|
+
duration=_core.perf_counter() - began,
|
|
608
|
+
result=value if (ok and include_result) else None,
|
|
609
|
+
context=context,
|
|
610
|
+
)
|
|
611
|
+
_log(
|
|
612
|
+
lg,
|
|
613
|
+
level,
|
|
614
|
+
"audit %s called from %s: %s%s (%.3f s)",
|
|
615
|
+
_format_call(name, arguments, 200),
|
|
616
|
+
caller,
|
|
617
|
+
record.status,
|
|
618
|
+
f" {record.error}" if record.error else "",
|
|
619
|
+
record.duration,
|
|
620
|
+
context=context,
|
|
621
|
+
extra={"audit": record},
|
|
622
|
+
)
|
|
623
|
+
if sink is not None:
|
|
624
|
+
try:
|
|
625
|
+
sink(record)
|
|
626
|
+
except Exception:
|
|
627
|
+
lg.exception("audit sink for %s failed", name)
|
|
628
|
+
|
|
629
|
+
return _instrument(f, start, finish)
|
|
630
|
+
|
|
631
|
+
return decorate if func is None else decorate(func)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ------------------------------------------------------------------------------ trace
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _otel_hooks(
|
|
638
|
+
f: Callable[..., Any], span_name: str | None
|
|
639
|
+
) -> tuple[Callable[[], Any], Callable[[Any, bool, Any], None]]:
|
|
640
|
+
"""Import OpenTelemetry now and return ``(open_span, close_span)`` hooks for ``trace``."""
|
|
641
|
+
try:
|
|
642
|
+
from opentelemetry import context as otel_context
|
|
643
|
+
from opentelemetry import trace as otel_trace
|
|
644
|
+
except ImportError as exc:
|
|
645
|
+
raise ImportError(
|
|
646
|
+
"trace(otel=True) needs the 'opentelemetry-api' package: pip install opentelemetry-api"
|
|
647
|
+
) from exc
|
|
648
|
+
tracer = otel_trace.get_tracer("python_wrap")
|
|
649
|
+
name = span_name or _core.qualname(f)
|
|
650
|
+
module = getattr(f, "__module__", None)
|
|
651
|
+
qual = getattr(f, "__qualname__", name)
|
|
652
|
+
|
|
653
|
+
def open_span() -> Any:
|
|
654
|
+
attributes: dict[str, Any] = {"code.function": qual.rpartition(".")[2]}
|
|
655
|
+
if module:
|
|
656
|
+
attributes["code.namespace"] = module
|
|
657
|
+
for key, value in current_log_context().items():
|
|
658
|
+
attributes[f"python_wrap.context.{key}"] = (
|
|
659
|
+
value if isinstance(value, (str, bool, int, float)) else str(value)
|
|
660
|
+
)
|
|
661
|
+
span = tracer.start_span(name, attributes=attributes)
|
|
662
|
+
token = otel_context.attach(otel_trace.set_span_in_context(span))
|
|
663
|
+
return span, token
|
|
664
|
+
|
|
665
|
+
def close_span(state: Any, ok: bool, value: Any) -> None:
|
|
666
|
+
span, token = state
|
|
667
|
+
try:
|
|
668
|
+
if not ok:
|
|
669
|
+
if isinstance(value, Exception):
|
|
670
|
+
span.record_exception(value)
|
|
671
|
+
span.set_status(
|
|
672
|
+
otel_trace.Status(otel_trace.StatusCode.ERROR, type(value).__name__)
|
|
673
|
+
)
|
|
674
|
+
finally:
|
|
675
|
+
otel_context.detach(token)
|
|
676
|
+
span.end()
|
|
677
|
+
|
|
678
|
+
return open_span, close_span
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@overload
|
|
682
|
+
def trace(func: F, /) -> F: ...
|
|
683
|
+
@overload
|
|
684
|
+
def trace(
|
|
685
|
+
func: None = None,
|
|
686
|
+
/,
|
|
687
|
+
*,
|
|
688
|
+
logger: LoggerLike = None,
|
|
689
|
+
level: int = logging.DEBUG,
|
|
690
|
+
limit: int | None = None,
|
|
691
|
+
otel: bool = False,
|
|
692
|
+
span_name: str | None = None,
|
|
693
|
+
) -> Callable[[F], F]: ...
|
|
694
|
+
def trace(
|
|
695
|
+
func: Callable[..., Any] | None = None,
|
|
696
|
+
/,
|
|
697
|
+
*,
|
|
698
|
+
logger: LoggerLike = None,
|
|
699
|
+
level: int = logging.DEBUG,
|
|
700
|
+
limit: int | None = None,
|
|
701
|
+
otel: bool = False,
|
|
702
|
+
span_name: str | None = None,
|
|
703
|
+
) -> Any:
|
|
704
|
+
"""Log entry, the call stack that led to the call, and exit.
|
|
705
|
+
|
|
706
|
+
Three messages per call: ``Entering f``, the call stack (python_wrap's own frames are
|
|
707
|
+
left out, outermost first), and ``Exiting f`` (or ``Exiting f (raised ValueError)``).
|
|
708
|
+
|
|
709
|
+
With ``otel=True`` the call is also wrapped in an OpenTelemetry span (see Args).
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
func: The function, when used as a bare ``@trace``.
|
|
713
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
714
|
+
level: Log level. Defaults to ``DEBUG``.
|
|
715
|
+
limit: Keep only this many innermost frames; ``None`` keeps all.
|
|
716
|
+
otel: Open an OpenTelemetry span around every call, using the tracer
|
|
717
|
+
``opentelemetry.trace.get_tracer("python_wrap")``. The span is current for the
|
|
718
|
+
whole call (also across ``await``), carries ``code.function``,
|
|
719
|
+
``code.namespace`` and the fields of the active `log_context` as
|
|
720
|
+
``python_wrap.context.<name>`` attributes, and on an exception records it and
|
|
721
|
+
sets the error status. ``opentelemetry-api`` is imported only when this is
|
|
722
|
+
true; python-wrap itself never depends on it.
|
|
723
|
+
span_name: Span name; defaults to the function's qualified name.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
The decorated function, with its signature preserved.
|
|
727
|
+
|
|
728
|
+
Raises:
|
|
729
|
+
ImportError: At decoration time, if ``otel=True`` and ``opentelemetry-api`` is not
|
|
730
|
+
installed.
|
|
731
|
+
|
|
732
|
+
Example:
|
|
733
|
+
>>> @trace(limit=5)
|
|
734
|
+
... def handler(): ...
|
|
735
|
+
"""
|
|
736
|
+
|
|
737
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
738
|
+
lg = _core.get_logger(logger)
|
|
739
|
+
name = _core.qualname(f)
|
|
740
|
+
open_span, close_span = _otel_hooks(f, span_name) if otel else (None, None)
|
|
741
|
+
|
|
742
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
743
|
+
span_state = open_span() if open_span is not None else None
|
|
744
|
+
if not lg.isEnabledFor(level):
|
|
745
|
+
return span_state
|
|
746
|
+
_log(lg, level, "Entering %s", name)
|
|
747
|
+
frame = _external_frame()
|
|
748
|
+
if frame is not None:
|
|
749
|
+
entries: list[traceback.FrameSummary] = list(traceback.extract_stack(frame))
|
|
750
|
+
if limit is not None:
|
|
751
|
+
entries = entries[-limit:] if limit > 0 else []
|
|
752
|
+
text = "".join(traceback.format_list(entries)).rstrip()
|
|
753
|
+
_log(lg, level, "Call stack for %s:\n%s", name, text, suffix=False)
|
|
754
|
+
return span_state
|
|
755
|
+
|
|
756
|
+
def finish(state: Any, ok: bool, value: Any) -> None:
|
|
757
|
+
try:
|
|
758
|
+
if lg.isEnabledFor(level):
|
|
759
|
+
suffix = "" if ok else f" (raised {type(value).__name__})"
|
|
760
|
+
_log(lg, level, "Exiting %s%s", name, suffix)
|
|
761
|
+
finally:
|
|
762
|
+
if close_span is not None:
|
|
763
|
+
close_span(state, ok, value)
|
|
764
|
+
|
|
765
|
+
return _instrument(f, start, finish)
|
|
766
|
+
|
|
767
|
+
return decorate if func is None else decorate(func)
|