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/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)