-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy path_logging.py
More file actions
152 lines (120 loc) · 4.82 KB
/
_logging.py
File metadata and controls
152 lines (120 loc) · 4.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# from https://github.com/scverse/spatialdata/blob/main/src/spatialdata/_logging.py
from __future__ import annotations
import logging
import re
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING
from ._settings import _VERBOSITY_TO_LOGLEVEL, Verbosity
if TYPE_CHECKING: # pragma: no cover
from _pytest.logging import LogCaptureFixture
# Holds the public-facing function name (e.g. "render_shapes") for log messages.
# Set at the top of each _render_* entry point so that all downstream helpers
# report the user-visible origin rather than internal function names.
_log_context: ContextVar[str] = ContextVar("_log_context", default="")
class _ContextFilter(logging.Filter):
"""Inject the public function name from ``_log_context`` into log records."""
def filter(self, record: logging.LogRecord) -> bool:
ctx = _log_context.get()
if ctx:
record.funcName = ctx
return True
def _setup_logger() -> logging.Logger:
from rich.console import Console
from rich.logging import RichHandler
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
console = Console(force_terminal=True)
if console.is_jupyter is True:
console.is_jupyter = False
ch = RichHandler(show_path=False, console=console, show_time=False)
ch.setFormatter(logging.Formatter("%(funcName)s: %(message)s"))
ch.addFilter(_ContextFilter())
logger.addHandler(ch)
# this prevents double outputs
logger.propagate = False
return logger
logger = _setup_logger()
def set_verbosity(verbosity: Verbosity | int | str) -> None:
"""Set the verbosity level of the spatialdata-plot logger.
Mirrors scanpy's verbosity convention.
Parameters
----------
verbosity
The verbosity level. Accepts a :class:`Verbosity` enum member,
an ``int`` (0–3), or a ``str`` (e.g. ``"warning"``, ``"info"``).
"""
if isinstance(verbosity, str):
try:
verbosity = Verbosity[verbosity.lower()]
except KeyError:
msg = f"Cannot set verbosity to {verbosity!r}. Accepted string values are: {list(Verbosity.__members__)}"
raise ValueError(msg) from None
else:
verbosity = Verbosity(verbosity)
logger.setLevel(_VERBOSITY_TO_LOGLEVEL[verbosity])
@contextmanager
def logger_warns(
caplog: LogCaptureFixture,
logger: logging.Logger,
match: str | None = None,
level: int = logging.WARNING,
) -> Iterator[None]:
"""
Context manager similar to pytest.warns, but for logging.Logger.
Usage:
with logger_warns(caplog, logger, match="Found 1 NaN"):
call_code_that_logs()
"""
# Store initial record count to only check new records
initial_record_count = len(caplog.records)
# Add caplog's handler directly to the logger to capture logs even if propagate=False
handler = caplog.handler
logger.addHandler(handler)
original_level = logger.level
logger.setLevel(level)
# Use caplog.at_level to ensure proper capture setup
with caplog.at_level(level, logger=logger.name):
try:
yield
finally:
logger.removeHandler(handler)
logger.setLevel(original_level)
# Only check records that were added during this context
records = [r for r in caplog.records[initial_record_count:] if r.levelno >= level]
if match is not None:
pattern = re.compile(match)
if not any(pattern.search(r.getMessage()) for r in records):
msgs = [r.getMessage() for r in records]
raise AssertionError(f"Did not find log matching {match!r} in records: {msgs!r}")
@contextmanager
def logger_no_warns(
caplog: LogCaptureFixture,
logger: logging.Logger,
match: str | None = None,
level: int = logging.WARNING,
) -> Iterator[None]:
"""Assert that no log record matching *match* is emitted.
Counterpart to :func:`logger_warns`.
"""
initial_record_count = len(caplog.records)
handler = caplog.handler
logger.addHandler(handler)
original_level = logger.level
logger.setLevel(level)
with caplog.at_level(level, logger=logger.name):
try:
yield
finally:
logger.removeHandler(handler)
logger.setLevel(original_level)
records = [r for r in caplog.records[initial_record_count:] if r.levelno >= level]
if match is not None:
pattern = re.compile(match)
matching = [r.getMessage() for r in records if pattern.search(r.getMessage())]
if matching:
raise AssertionError(f"Found unexpected log matching {match!r}: {matching!r}")
elif records:
msgs = [r.getMessage() for r in records]
raise AssertionError(f"Expected no log records at level>={level}, but got: {msgs!r}")