xakki-log-python 0.4.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,217 @@
1
+ """Wire and developer-facing formatters for stdlib :mod:`logging`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from datetime import UTC, datetime
10
+ from typing import Final
11
+
12
+ from log_python.config import PylogConfig
13
+ from log_python.context import get_bound_context
14
+ from log_python.manager import LogManager
15
+ from log_python.redactor import Redactor
16
+
17
+ DEFAULT_MAX_BYTES: Final[int] = 1400
18
+ _ELLIPSIS: Final[str] = "…"
19
+ _REQUIRED_EXTRA_KEYS: Final[tuple[str, ...]] = ("app_name", "app_env", "app_ver", "log_ver", "tier")
20
+ _LEVELS: Final[tuple[tuple[int, str, int], ...]] = (
21
+ (60, "emergency", 600),
22
+ (55, "alert", 550),
23
+ (logging.CRITICAL, "critical", 500),
24
+ (logging.ERROR, "error", 400),
25
+ (logging.WARNING, "warning", 300),
26
+ (25, "notice", 250),
27
+ (logging.INFO, "info", 200),
28
+ (logging.DEBUG, "debug", 100),
29
+ )
30
+
31
+
32
+ def _level(levelno: int) -> tuple[str, int]:
33
+ for threshold, name, number in _LEVELS:
34
+ if levelno >= threshold:
35
+ return name, number
36
+ return "debug", 100
37
+
38
+
39
+ def _safe_json_value(value: object, redactor: Redactor) -> object:
40
+ try:
41
+ redacted = redactor.redact(value)
42
+ json.dumps(redacted, ensure_ascii=False, allow_nan=False)
43
+ except Exception:
44
+ return "<unserializable>"
45
+ return redacted
46
+
47
+
48
+ def _json_bytes(data: Mapping[str, object]) -> bytes:
49
+ return json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False, default=str).encode()
50
+
51
+
52
+ def _truncate_utf8(value: str, byte_limit: int) -> str:
53
+ marker_bytes = len(_ELLIPSIS.encode())
54
+ if byte_limit <= marker_bytes:
55
+ return _ELLIPSIS
56
+ raw = value.encode()
57
+ if len(raw) <= byte_limit:
58
+ return value
59
+ return raw[: byte_limit - marker_bytes].decode(errors="ignore") + _ELLIPSIS
60
+
61
+
62
+ def _string_slots(data: dict[str, object]) -> list[tuple[dict[str, object], str]]:
63
+ slots: list[tuple[dict[str, object], str]] = []
64
+ for key in ("message", "channel"):
65
+ if isinstance(data.get(key), str):
66
+ slots.append((data, key))
67
+ for container_name in ("context", "extra"):
68
+ container = data.get(container_name)
69
+ if isinstance(container, dict):
70
+ slots.extend(
71
+ (container, key)
72
+ for key, value in container.items()
73
+ if isinstance(key, str) and isinstance(value, str) and not (container_name == "extra" and key in _REQUIRED_EXTRA_KEYS)
74
+ )
75
+ return slots
76
+
77
+
78
+ def trim_log(data: dict[str, object], max_bytes: int = DEFAULT_MAX_BYTES) -> str:
79
+ """Serialize ``data`` as valid JSON no larger than ``max_bytes`` when possible."""
80
+ max_bytes = max(64, max_bytes)
81
+ encoded = _json_bytes(data)
82
+ previous = len(encoded) + 1
83
+ while len(encoded) > max_bytes and len(encoded) < previous:
84
+ previous = len(encoded)
85
+ slots = _string_slots(data)
86
+ if not slots:
87
+ break
88
+ excess = len(encoded) - max_bytes
89
+ reduction = max(1, excess // len(slots) + 1)
90
+ changed = False
91
+ for container, key in slots:
92
+ value = container[key]
93
+ if not isinstance(value, str):
94
+ continue
95
+ current = len(value.encode())
96
+ shortened = _truncate_utf8(value, max(len(_ELLIPSIS.encode()), current - reduction))
97
+ if shortened != value:
98
+ container[key] = shortened
99
+ changed = True
100
+ if not changed:
101
+ break
102
+ encoded = _json_bytes(data)
103
+ if len(encoded) > max_bytes:
104
+ context = data.get("context")
105
+ preserved: dict[str, object] = {}
106
+ if isinstance(context, dict):
107
+ for key in ("log_type", "request_id", "file", "exception", "exception_code"):
108
+ if key in context:
109
+ preserved[key] = context[key]
110
+ preserved["_truncated"] = True
111
+ data["context"] = preserved
112
+ extra = data.get("extra")
113
+ data["extra"] = {key: extra[key] for key in _REQUIRED_EXTRA_KEYS if key in extra} if isinstance(extra, dict) else {}
114
+ encoded = _json_bytes(data)
115
+ while len(encoded) > max_bytes:
116
+ slots = _string_slots(data)
117
+ if not slots:
118
+ break
119
+ longest_container, longest_key = max(slots, key=lambda slot: len(str(slot[0][slot[1]]).encode()))
120
+ value = longest_container[longest_key]
121
+ if not isinstance(value, str) or value == _ELLIPSIS:
122
+ break
123
+ longest_container[longest_key] = _truncate_utf8(value, max(len(_ELLIPSIS.encode()), len(value.encode()) // 2))
124
+ encoded = _json_bytes(data)
125
+ return encoded.decode()
126
+
127
+
128
+ class ExtraFilter(logging.Filter):
129
+ """Attach stable process metadata to every record."""
130
+
131
+ def __init__(self, config: PylogConfig, argv: Sequence[str] | None = None) -> None:
132
+ super().__init__()
133
+ values = {
134
+ "app_name": config.extra.app_name or "",
135
+ "app_env": config.extra.app_env or "",
136
+ "app_ver": config.extra.app_ver or "",
137
+ "log_ver": config.extra.log_ver,
138
+ "tier": config.extra.tier or "",
139
+ "release_tag": config.extra.release_tag,
140
+ "release_time": config.extra.release_time,
141
+ }
142
+ self._extra = {key: value for key, value in values.items() if value is not None}
143
+ resolved_argv = tuple(sys.argv if argv is None else argv)
144
+ if resolved_argv:
145
+ self._extra["console_argv"] = " ".join(resolved_argv)
146
+
147
+ def filter(self, record: logging.LogRecord) -> bool:
148
+ record.pylog_extra = dict(self._extra)
149
+ return True
150
+
151
+
152
+ class JsonFormatter(logging.Formatter):
153
+ """Emit one newline-terminated JSON record conforming to the shared wire contract."""
154
+
155
+ def __init__(self, config: PylogConfig | None = None, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
156
+ super().__init__()
157
+ self._config = config or PylogConfig.from_env()
158
+ self._manager = LogManager(self._config)
159
+ self._redactor = Redactor.from_config(self._config)
160
+ self._max_bytes = max_bytes
161
+
162
+ def format(self, record: logging.LogRecord) -> str:
163
+ try:
164
+ raw_context = getattr(record, "context", None)
165
+ context = get_bound_context()
166
+ if isinstance(raw_context, Mapping):
167
+ context.update(raw_context)
168
+ if record.exc_info and isinstance(record.exc_info[1], BaseException) and "exception" not in context:
169
+ context["exception"] = record.exc_info[1]
170
+ message, enriched = self._manager.append_context(record.levelno, record.getMessage(), context)
171
+ message = self._redactor.redact_value(message)
172
+ level_name, level = _level(record.levelno)
173
+ raw_extra = getattr(record, "pylog_extra", {})
174
+ extra = raw_extra if isinstance(raw_extra, Mapping) else {}
175
+ data: dict[str, object] = {
176
+ "datetime": datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="microseconds"),
177
+ "level": level,
178
+ "level_name": level_name,
179
+ "channel": record.name,
180
+ "message": message,
181
+ "context": {key: _safe_json_value(value, self._redactor) for key, value in enriched.items() if value is not None},
182
+ "extra": {str(key): _safe_json_value(value, self._redactor) for key, value in extra.items() if value is not None},
183
+ }
184
+ return trim_log(data, self._max_bytes) + "\n"
185
+ except Exception:
186
+ return '{"level":400,"level_name":"error","channel":"pylog","message":"logging format error","context":{},"extra":{}}\n'
187
+
188
+
189
+ class LineFormatter(JsonFormatter):
190
+ """Readable single-line formatter with escaped user-controlled line breaks."""
191
+
192
+ def format(self, record: logging.LogRecord) -> str:
193
+ data = json.loads(super().format(record))
194
+ message = str(data["message"]).replace("\r", "\\r").replace("\n", "\\n")
195
+ context = json.dumps(data["context"], ensure_ascii=False, separators=(",", ":"))
196
+ extra = json.dumps(data["extra"], ensure_ascii=False, separators=(",", ":"))
197
+ return f"[{data['datetime']}] {data['channel']}.{data['level_name']}: {message} {context} {extra}\n"
198
+
199
+
200
+ class ConsoleFormatter(LineFormatter):
201
+ """ANSI-coloured formatter for local interactive development."""
202
+
203
+ _COLORS: Final[Mapping[str, str]] = {
204
+ "debug": "1;36",
205
+ "info": "0;32",
206
+ "notice": "0;34",
207
+ "warning": "1;33",
208
+ "error": "0;31",
209
+ "critical": "0;31",
210
+ "alert": "0;35",
211
+ "emergency": "0;35",
212
+ }
213
+
214
+ def format(self, record: logging.LogRecord) -> str:
215
+ level_name, _ = _level(record.levelno)
216
+ line = super().format(record).rstrip("\n")
217
+ return f"\033[{self._COLORS[level_name]}m{line}\033[0m\n"
@@ -0,0 +1 @@
1
+ """Optional logging handlers."""
@@ -0,0 +1,42 @@
1
+ """Redis list handler; importing this module does not import a Redis client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Protocol
7
+
8
+
9
+ class Pipeline(Protocol):
10
+ def lpush(self, key: str, value: str) -> Pipeline: ...
11
+ def ltrim(self, key: str, start: int, end: int) -> Pipeline: ...
12
+ def execute(self) -> object: ...
13
+
14
+
15
+ class RedisClient(Protocol):
16
+ def lpush(self, key: str, value: str) -> object: ...
17
+ def pipeline(self, transaction: bool = True) -> Pipeline: ...
18
+
19
+
20
+ class RedisHandler(logging.Handler):
21
+ """Best-effort ``LPUSH`` handler with an optional ``LTRIM`` cap."""
22
+
23
+ def __init__(self, client: RedisClient, *, key: str = "logs", cap_size: int = 10_000, level: int = logging.INFO) -> None:
24
+ super().__init__(level)
25
+ if cap_size < 0:
26
+ raise ValueError("cap_size must be non-negative")
27
+ self._client = client
28
+ self._key = key
29
+ self._cap_size = cap_size
30
+
31
+ def emit(self, record: logging.LogRecord) -> None:
32
+ try:
33
+ payload = self.format(record)
34
+ if self._cap_size > 0:
35
+ pipeline = self._client.pipeline(transaction=True)
36
+ pipeline.lpush(self._key, payload)
37
+ pipeline.ltrim(self._key, 0, self._cap_size - 1)
38
+ pipeline.execute()
39
+ else:
40
+ self._client.lpush(self._key, payload)
41
+ except Exception:
42
+ self.handleError(record)
@@ -0,0 +1,52 @@
1
+ """Framework-neutral queue-worker heartbeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+
10
+ from log_python.const import LOGGER_COUNT, LOGGER_MONITORING, LOGGER_STATUS
11
+
12
+
13
+ class Heartbeat:
14
+ def __init__(self, logger: logging.Logger, *, name: str = "default", interval: float = 120, clock: Callable[[], float] = time.monotonic) -> None:
15
+ self._logger = logger
16
+ self._name = name
17
+ self._interval = interval
18
+ self._clock = clock
19
+ self._last = 0.0
20
+ self._stop = threading.Event()
21
+ self._thread: threading.Thread | None = None
22
+
23
+ def tick(self, *, force: bool = False) -> bool:
24
+ now = self._clock()
25
+ if not force and (self._interval <= 0 or now - self._last <= self._interval):
26
+ return False
27
+ self._logger.info("heartbeat: %s", self._name, extra={"context": {LOGGER_MONITORING: "queue", LOGGER_COUNT: 0}})
28
+ self._last = now
29
+ return True
30
+
31
+ def start(self) -> None:
32
+ if self._interval <= 0:
33
+ return
34
+ if self._thread is not None and self._thread.is_alive():
35
+ return
36
+ self._stop.clear()
37
+ self._thread = threading.Thread(target=self._run, name=f"pylog-heartbeat-{self._name}", daemon=True)
38
+ self._thread.start()
39
+
40
+ @property
41
+ def running(self) -> bool:
42
+ return self._thread is not None and self._thread.is_alive()
43
+
44
+ def _run(self) -> None:
45
+ while not self._stop.wait(self._interval):
46
+ self.tick(force=True)
47
+
48
+ def stop(self, *, status: int = 0) -> None:
49
+ self._stop.set()
50
+ if self._thread is not None and self._thread is not threading.current_thread():
51
+ self._thread.join(timeout=max(1.0, self._interval))
52
+ self._logger.info("Queue stop signal", extra={"context": {LOGGER_MONITORING: "queue-stop", LOGGER_STATUS: status}})
@@ -0,0 +1 @@
1
+ """Optional framework integrations."""
@@ -0,0 +1,87 @@
1
+ """Optional SQLAlchemy slow-query instrumentation (dependency injected)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import logging
8
+ import re
9
+ import time
10
+ from collections.abc import Callable
11
+ from typing import Any, Protocol, cast
12
+
13
+ from log_python.redactor import Redactor
14
+
15
+ _SQL_RE = re.compile(r"(UPDATE|DELETE\s+FROM|INSERT\s+INTO|FROM)\s+[`\"\[]?([\w.]+)[`\"\]]?", re.IGNORECASE)
16
+
17
+
18
+ class EventApi(Protocol):
19
+ def listen(self, target: object, name: str, callback: Callable[..., None]) -> None: ...
20
+
21
+
22
+ def classify_sql(statement: str) -> tuple[str, str]:
23
+ match = _SQL_RE.search(statement)
24
+ if not match:
25
+ return "", ""
26
+ sql_type = re.sub(r"\s+", " ", match.group(1).upper())
27
+ sql_type = {"FROM": "SELECT", "DELETE FROM": "DELETE", "INSERT INTO": "INSERT"}.get(sql_type, sql_type)
28
+ return sql_type, match.group(2)
29
+
30
+
31
+ def _safe_bindings(parameters: object, redactor: Redactor) -> str:
32
+ try:
33
+ if isinstance(parameters, (list, tuple)) and len(parameters) >= 20:
34
+ return ""
35
+ values = (
36
+ [f"`string({len(value)})`" if isinstance(value, str) and len(value) > 512 else value for value in parameters]
37
+ if isinstance(parameters, (list, tuple))
38
+ else parameters
39
+ )
40
+ return json.dumps(redactor.redact(values), default=str, ensure_ascii=False)
41
+ except Exception:
42
+ return ""
43
+
44
+
45
+ def install_sqlalchemy_logging(
46
+ engine: object,
47
+ *,
48
+ logger: logging.Logger | None = None,
49
+ select_threshold_ms: int = 200,
50
+ all_threshold_ms: int = 500,
51
+ include_bindings: bool = False,
52
+ event_api: EventApi | None = None,
53
+ clock: Callable[[], float] = time.perf_counter,
54
+ ) -> None:
55
+ if event_api is None:
56
+ event_api = cast(EventApi, importlib.import_module("sqlalchemy.event"))
57
+ target_logger = logger or logging.getLogger("sql")
58
+ redactor = Redactor()
59
+
60
+ def before(_conn: object, _cursor: object, _statement: str, _parameters: object, context: Any, _executemany: bool) -> None:
61
+ started = clock()
62
+ if isinstance(context, dict):
63
+ context["_pylog_started"] = started
64
+ else:
65
+ context._pylog_started = started
66
+
67
+ def after(_conn: object, _cursor: object, statement: str, parameters: object, context: Any, _executemany: bool) -> None:
68
+ if "telescope" in statement.lower():
69
+ return
70
+ started = context.get("_pylog_started") if isinstance(context, dict) else getattr(context, "_pylog_started", clock())
71
+ started_value = float(started) if isinstance(started, (int, float, str)) else clock()
72
+ milliseconds = round((clock() - started_value) * 1000)
73
+ sql_type, table = classify_sql(statement)
74
+ threshold = select_threshold_ms if sql_type == "SELECT" and select_threshold_ms else all_threshold_ms
75
+ if threshold and milliseconds < threshold:
76
+ return
77
+ fields = {
78
+ "db_table": table,
79
+ "db_time_ms": milliseconds,
80
+ "db_bindings": _safe_bindings(parameters, redactor) if include_bindings else "",
81
+ "tag": "sql",
82
+ "sql_type": sql_type,
83
+ }
84
+ target_logger.info(statement, extra={"context": fields})
85
+
86
+ event_api.listen(engine, "before_cursor_execute", before)
87
+ event_api.listen(engine, "after_cursor_execute", after)
log_python/logtype.py ADDED
@@ -0,0 +1,171 @@
1
+ """Log origin classification, ported from the reference `src/LogType.php` (spec
2
+ `docs/LoggingRules.md` §4.3.1, field `log_type`).
3
+
4
+ Classifies WHO initiated a log record:
5
+
6
+ - ``logger`` — an explicit call in code (the default).
7
+ - ``trigger`` — reserved for a runtime warning/error integration.
8
+ - ``exception`` — an uncaught-exception handler, or an explicit log call carrying
9
+ ``context['exception']``.
10
+ - ``fatal`` — reserved for a supervisor/native-fatal integration.
11
+
12
+ `install_handlers()` provides opt-in, idempotent and chained `sys.excepthook`,
13
+ `threading.excepthook` and `warnings.showwarning` integration. Python has no PHP-style `error_get_last()` at
14
+ normal `atexit`, so this module does not fabricate a reliable `fatal` event from a
15
+ clean shutdown; native fatal signals remain the responsibility of the process
16
+ supervisor/faulthandler.
17
+
18
+ Python-idiom divergence from the reference (documented, not an oversight): PHP's
19
+ `LogType::$origin` is a single process-global static (correct for PHP-FPM's one-request-
20
+ per-process model). A bare global would leak across concurrent requests/tasks in a
21
+ threaded or async Python process, so this port uses a `contextvars.ContextVar` instead —
22
+ each thread/async-task context gets its own origin, isolated the way the spec's own
23
+ `request_id` stability period ("stable within one request") already implies concurrent
24
+ call-chains must not step on each other. The installed thread hook sets origin in the
25
+ failing thread before emitting and chaining the record.
26
+
27
+ Method names avoid `set`/`reset` as bare functions (`flake8-builtins`, enabled in this
28
+ project's `ruff.toml`, flags shadowing the `set` builtin); the reference's `LogType::set()`
29
+ / `::reset()` become `LogType.set_origin()` / `LogType.reset_origin()` here.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import contextvars
35
+ import logging
36
+ import sys
37
+ import threading
38
+ import warnings
39
+ from collections.abc import Callable
40
+ from types import TracebackType
41
+ from typing import ClassVar, Final, TextIO
42
+
43
+ LOGGER: Final[str] = "logger"
44
+ TRIGGER: Final[str] = "trigger"
45
+ EXCEPTION: Final[str] = "exception"
46
+ FATAL: Final[str] = "fatal"
47
+
48
+ _ORIGIN: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar("pylog_log_type_origin", default=None)
49
+ _HANDLER_LOCK: Final[threading.Lock] = threading.Lock()
50
+ _INSTALLED = False
51
+ _PREVIOUS_SYS_HOOK = sys.excepthook
52
+ _PREVIOUS_THREAD_HOOK = threading.excepthook
53
+ _PREVIOUS_WARNING_HOOK = warnings.showwarning
54
+ _INSTALLED_SYS_HOOK: Callable[[type[BaseException], BaseException, TracebackType | None], None] | None = None
55
+ _INSTALLED_THREAD_HOOK: Callable[[threading.ExceptHookArgs], None] | None = None
56
+ _INSTALLED_WARNING_HOOK: Callable[[Warning | str, type[Warning], str, int, TextIO | None, str | None], None] | None = None
57
+
58
+
59
+ class LogType:
60
+ """Namespace mirroring the reference `LogType` class (its four constants + the
61
+ process/context-scoped origin flag). Not instantiated — every member is a classmethod
62
+ or class-level constant, matching the PHP class's all-static shape.
63
+ """
64
+
65
+ LOGGER: Final[str] = LOGGER
66
+ TRIGGER: Final[str] = TRIGGER
67
+ EXCEPTION: Final[str] = EXCEPTION
68
+ FATAL: Final[str] = FATAL
69
+
70
+ _origin: ClassVar[contextvars.ContextVar[str | None]] = _ORIGIN
71
+
72
+ @classmethod
73
+ def current(cls) -> str:
74
+ """The current origin, or `LOGGER` when nothing more specific has been set —
75
+ reference `LogType::current()`.
76
+ """
77
+ return cls._origin.get() or cls.LOGGER
78
+
79
+ @classmethod
80
+ def set_origin(cls, value: str | None) -> None:
81
+ """Set the current context's origin. Reference `LogType::set()`."""
82
+ cls._origin.set(value)
83
+
84
+ @classmethod
85
+ def reset_origin(cls) -> None:
86
+ """Clear the current context's origin back to the `LOGGER` default. Reference
87
+ `LogType::reset()`.
88
+ """
89
+ cls._origin.set(None)
90
+
91
+
92
+ def handlers_installed() -> bool:
93
+ """Whether this module currently owns the process exception hooks."""
94
+ return _INSTALLED
95
+
96
+
97
+ def install_handlers() -> None:
98
+ """Install idempotent, chained uncaught-exception hooks."""
99
+ global _INSTALLED, _PREVIOUS_SYS_HOOK, _PREVIOUS_THREAD_HOOK, _PREVIOUS_WARNING_HOOK
100
+ global _INSTALLED_SYS_HOOK, _INSTALLED_THREAD_HOOK, _INSTALLED_WARNING_HOOK
101
+ with _HANDLER_LOCK:
102
+ if _INSTALLED:
103
+ return
104
+ _PREVIOUS_SYS_HOOK = sys.excepthook
105
+ _PREVIOUS_THREAD_HOOK = threading.excepthook
106
+ _PREVIOUS_WARNING_HOOK = warnings.showwarning
107
+
108
+ def sys_hook(exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None) -> None:
109
+ LogType.set_origin(LogType.EXCEPTION)
110
+ try:
111
+ logging.getLogger("log_python.uncaught").critical("uncaught exception", exc_info=(exc_type, exc, tb))
112
+ _PREVIOUS_SYS_HOOK(exc_type, exc, tb)
113
+ finally:
114
+ LogType.reset_origin()
115
+
116
+ def thread_hook(args: threading.ExceptHookArgs) -> None:
117
+ LogType.set_origin(LogType.EXCEPTION)
118
+ try:
119
+ if args.exc_value is not None:
120
+ logging.getLogger("log_python.uncaught").critical(
121
+ "uncaught thread exception",
122
+ exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
123
+ )
124
+ _PREVIOUS_THREAD_HOOK(args)
125
+ finally:
126
+ LogType.reset_origin()
127
+
128
+ def warning_hook(
129
+ message: Warning | str,
130
+ category: type[Warning],
131
+ filename: str,
132
+ lineno: int,
133
+ file: TextIO | None = None,
134
+ line: str | None = None,
135
+ ) -> None:
136
+ LogType.set_origin(LogType.TRIGGER)
137
+ try:
138
+ logging.getLogger("log_python.warning").warning(
139
+ str(message),
140
+ extra={"context": {"warning_category": category.__name__, "warning_file": filename, "warning_line": lineno}},
141
+ )
142
+ _PREVIOUS_WARNING_HOOK(message, category, filename, lineno, file, line)
143
+ finally:
144
+ LogType.reset_origin()
145
+
146
+ sys.excepthook = sys_hook
147
+ threading.excepthook = thread_hook
148
+ warnings.showwarning = warning_hook
149
+ _INSTALLED_SYS_HOOK = sys_hook
150
+ _INSTALLED_THREAD_HOOK = thread_hook
151
+ _INSTALLED_WARNING_HOOK = warning_hook
152
+ _INSTALLED = True
153
+
154
+
155
+ def uninstall_handlers() -> None:
156
+ """Restore hooks captured by :func:`install_handlers`; primarily useful in tests."""
157
+ global _INSTALLED, _INSTALLED_SYS_HOOK, _INSTALLED_THREAD_HOOK, _INSTALLED_WARNING_HOOK
158
+ with _HANDLER_LOCK:
159
+ if not _INSTALLED:
160
+ return
161
+ if sys.excepthook is _INSTALLED_SYS_HOOK:
162
+ sys.excepthook = _PREVIOUS_SYS_HOOK
163
+ if threading.excepthook is _INSTALLED_THREAD_HOOK:
164
+ threading.excepthook = _PREVIOUS_THREAD_HOOK
165
+ if warnings.showwarning is _INSTALLED_WARNING_HOOK:
166
+ warnings.showwarning = _PREVIOUS_WARNING_HOOK
167
+ _INSTALLED_SYS_HOOK = None
168
+ _INSTALLED_THREAD_HOOK = None
169
+ _INSTALLED_WARNING_HOOK = None
170
+ _INSTALLED = False
171
+ LogType.reset_origin()