cloudru-qa-logging-context 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.
- cloudru_qa_logging_context-1.0.0.dist-info/METADATA +396 -0
- cloudru_qa_logging_context-1.0.0.dist-info/RECORD +11 -0
- cloudru_qa_logging_context-1.0.0.dist-info/WHEEL +4 -0
- cloudru_qa_logging_context-1.0.0.dist-info/entry_points.txt +2 -0
- cloudru_qa_logging_context-1.0.0.dist-info/licenses/LICENSE +202 -0
- logging_context/__init__.py +4 -0
- logging_context/handler.py +236 -0
- logging_context/hooks.py +23 -0
- logging_context/naming.py +88 -0
- logging_context/plugin.py +486 -0
- logging_context/py.typed +1 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Generator, Sequence
|
|
5
|
+
from pathlib import Path, PurePath, PureWindowsPath
|
|
6
|
+
from typing import Final, Protocol, cast
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from . import hooks
|
|
11
|
+
from .handler import HandlerOptions, LoggingContextHandler
|
|
12
|
+
from .naming import build_test_id, build_test_name, params_of
|
|
13
|
+
|
|
14
|
+
# The plugin module name is occupied by the entry point, so the instance
|
|
15
|
+
# is registered under a separate name.
|
|
16
|
+
PLUGIN_NAME: Final = "logging_context_runtime"
|
|
17
|
+
|
|
18
|
+
_LOGGER_NAME: Final = "logging_context"
|
|
19
|
+
|
|
20
|
+
DEFAULT_FORMAT: Final = "%(asctime)s.%(msecs)03d - %(levelname)s - %(message)s"
|
|
21
|
+
DEFAULT_DATE_FORMAT: Final = "%d.%m.%Y %H:%M:%S"
|
|
22
|
+
DEFAULT_LEVEL: Final = "INFO"
|
|
23
|
+
|
|
24
|
+
LAUNCH_ID_OPTION: Final = "--logging-context-launch-id"
|
|
25
|
+
LAUNCH_ID_ENV: Final = "LOGGING_CONTEXT_LAUNCH_ID"
|
|
26
|
+
LAUNCH_ID_INI: Final = "logging_context_launch_id"
|
|
27
|
+
|
|
28
|
+
_LAUNCH_ID_DEST: Final = "logging_context_launch_id"
|
|
29
|
+
_NO_INDENT: Final = -1
|
|
30
|
+
_CONTROLLER_ID: Final = "master"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _AttachmentTypes(Protocol):
|
|
34
|
+
TEXT: object
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Allure(Protocol):
|
|
38
|
+
attachment_type: _AttachmentTypes
|
|
39
|
+
|
|
40
|
+
def attach(self, body: str, *, name: str, attachment_type: object) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _HookDecorator(Protocol):
|
|
44
|
+
def __call__[T](self, function: T) -> T: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_WRAPPER = cast("_HookDecorator", pytest.hookimpl(wrapper=True))
|
|
48
|
+
_INNER_WRAPPER = cast("_HookDecorator", pytest.hookimpl(wrapper=True, trylast=True))
|
|
49
|
+
_FIRST = cast("_HookDecorator", pytest.hookimpl(tryfirst=True))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _ConfigureHook(Protocol):
|
|
53
|
+
def __call__(self, *, handler: LoggingContextHandler, config: pytest.Config) -> object: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
allure: _Allure | None
|
|
57
|
+
try:
|
|
58
|
+
import allure as _allure
|
|
59
|
+
except ImportError: # pragma: no cover - allure is optional
|
|
60
|
+
allure = None
|
|
61
|
+
else:
|
|
62
|
+
allure = cast("_Allure", _allure)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pytest_addhooks(pluginmanager: pytest.PytestPluginManager) -> None:
|
|
66
|
+
"""Register extension hooks.
|
|
67
|
+
|
|
68
|
+
:param pluginmanager: pytest configuration object.
|
|
69
|
+
"""
|
|
70
|
+
pluginmanager.add_hookspecs(hooks)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
74
|
+
"""Register plugin settings.
|
|
75
|
+
|
|
76
|
+
:param parser: pytest configuration object.
|
|
77
|
+
"""
|
|
78
|
+
parser.addoption(
|
|
79
|
+
LAUNCH_ID_OPTION,
|
|
80
|
+
action="store",
|
|
81
|
+
dest=_LAUNCH_ID_DEST,
|
|
82
|
+
default=None,
|
|
83
|
+
metavar="ID",
|
|
84
|
+
help=(
|
|
85
|
+
f"Идентификатор запуска: подкаталог внутри logging_context_dir. "
|
|
86
|
+
f"Приоритет: эта опция, затем {LAUNCH_ID_ENV}, затем {LAUNCH_ID_INI} из ini."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
parser.addini(
|
|
90
|
+
"logging_context_dir",
|
|
91
|
+
"Каталог для файлов логов. Пока не задан, плагин не активен. Относительный путь считается от rootdir.",
|
|
92
|
+
default="",
|
|
93
|
+
)
|
|
94
|
+
parser.addini(
|
|
95
|
+
LAUNCH_ID_INI,
|
|
96
|
+
f"Идентификатор запуска: подкаталог внутри logging_context_dir. "
|
|
97
|
+
f"Переопределяется опцией {LAUNCH_ID_OPTION} и переменной {LAUNCH_ID_ENV}.",
|
|
98
|
+
default="",
|
|
99
|
+
)
|
|
100
|
+
parser.addini(
|
|
101
|
+
"logging_context_loggers",
|
|
102
|
+
"Логгеры, с которых собирать записи. По умолчанию — root, то есть всё.",
|
|
103
|
+
type="args",
|
|
104
|
+
default=list[str](),
|
|
105
|
+
)
|
|
106
|
+
parser.addini(
|
|
107
|
+
"logging_context_level",
|
|
108
|
+
"Минимальный уровень записей.",
|
|
109
|
+
default=DEFAULT_LEVEL,
|
|
110
|
+
)
|
|
111
|
+
parser.addini(
|
|
112
|
+
"logging_context_format",
|
|
113
|
+
"Формат строки лога.",
|
|
114
|
+
default=DEFAULT_FORMAT,
|
|
115
|
+
)
|
|
116
|
+
parser.addini(
|
|
117
|
+
"logging_context_date_format",
|
|
118
|
+
"Формат времени в логах.",
|
|
119
|
+
default=DEFAULT_DATE_FORMAT,
|
|
120
|
+
)
|
|
121
|
+
parser.addini(
|
|
122
|
+
"logging_context_attach_allure",
|
|
123
|
+
"Прикреплять файлы логов к allure-отчёту.",
|
|
124
|
+
type="bool",
|
|
125
|
+
default=True,
|
|
126
|
+
)
|
|
127
|
+
parser.addini(
|
|
128
|
+
"logging_context_attach_fixtures_log",
|
|
129
|
+
"Прикреплять общий файл логов фикстур к каждому тесту.",
|
|
130
|
+
type="bool",
|
|
131
|
+
default=True,
|
|
132
|
+
)
|
|
133
|
+
parser.addini(
|
|
134
|
+
"logging_context_log_test_header",
|
|
135
|
+
"Писать в начало файла строку с идентификатором и параметрами теста.",
|
|
136
|
+
type="bool",
|
|
137
|
+
default=True,
|
|
138
|
+
)
|
|
139
|
+
parser.addini(
|
|
140
|
+
"logging_context_params_indent",
|
|
141
|
+
"Отступ при сериализации параметров теста в JSON. -1 — в одну строку.",
|
|
142
|
+
type="int",
|
|
143
|
+
default=_NO_INDENT,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
148
|
+
"""Create a handler and enable the plugin.
|
|
149
|
+
|
|
150
|
+
:param config: pytest configuration object.
|
|
151
|
+
"""
|
|
152
|
+
output_dir = _resolve_output_dir(config)
|
|
153
|
+
if output_dir is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
worker_id = _worker_id(config)
|
|
157
|
+
handler = LoggingContextHandler(
|
|
158
|
+
output_dir,
|
|
159
|
+
f"fixtures_{worker_id}.log",
|
|
160
|
+
# xdist workers share a directory, so filenames must be
|
|
161
|
+
# distinguishable across processes.
|
|
162
|
+
options=HandlerOptions(name_suffix="" if worker_id == _CONTROLLER_ID else worker_id),
|
|
163
|
+
)
|
|
164
|
+
handler.setLevel(_resolve_level(cast("str", config.getini("logging_context_level"))))
|
|
165
|
+
handler.setFormatter(
|
|
166
|
+
logging.Formatter(
|
|
167
|
+
fmt=str(cast("str", config.getini("logging_context_format"))),
|
|
168
|
+
datefmt=str(cast("str", config.getini("logging_context_date_format"))),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
plugin = LoggingContextPlugin(config, handler)
|
|
173
|
+
config.pluginmanager.register(plugin, PLUGIN_NAME)
|
|
174
|
+
cast("_ConfigureHook", config.hook.pytest_logging_context_configure)(handler=handler, config=config)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
178
|
+
"""Close the handler and disable the plugin.
|
|
179
|
+
|
|
180
|
+
:param config: pytest configuration object.
|
|
181
|
+
"""
|
|
182
|
+
plugin = cast("object", config.pluginmanager.get_plugin(PLUGIN_NAME))
|
|
183
|
+
if isinstance(plugin, LoggingContextPlugin):
|
|
184
|
+
plugin.shutdown()
|
|
185
|
+
config.pluginmanager.unregister(plugin)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class LoggingContextPlugin:
|
|
189
|
+
"""Switch log files for each test and attach them to Allure."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, config: pytest.Config, handler: LoggingContextHandler) -> None:
|
|
192
|
+
"""Initialize the object.
|
|
193
|
+
|
|
194
|
+
:param config: ``config`` setting value.
|
|
195
|
+
:param handler: ``handler`` setting value.
|
|
196
|
+
"""
|
|
197
|
+
self._handler = handler
|
|
198
|
+
self._attach_allure = bool(cast("bool", config.getini("logging_context_attach_allure")))
|
|
199
|
+
self._attach_fixtures_log = bool(cast("bool", config.getini("logging_context_attach_fixtures_log")))
|
|
200
|
+
self._log_test_header = bool(cast("bool", config.getini("logging_context_log_test_header")))
|
|
201
|
+
|
|
202
|
+
indent = int(cast("int", config.getini("logging_context_params_indent")))
|
|
203
|
+
self._params_indent = None if indent == _NO_INDENT else indent
|
|
204
|
+
|
|
205
|
+
self._item: pytest.Item | None = None
|
|
206
|
+
self._logger_names: list[str] = [
|
|
207
|
+
str(name) for name in cast("list[str]", config.getini("logging_context_loggers"))
|
|
208
|
+
]
|
|
209
|
+
self._targets = _LoggerTargets(handler, self._logger_names)
|
|
210
|
+
self._targets.attach()
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def handler(self) -> LoggingContextHandler:
|
|
214
|
+
"""Handler used by the plugin to write logs."""
|
|
215
|
+
return self._handler
|
|
216
|
+
|
|
217
|
+
@_WRAPPER
|
|
218
|
+
def pytest_runtest_protocol(self, item: pytest.Item) -> Generator[None, object, object]:
|
|
219
|
+
"""Remember the test so its name is available in attempt hooks."""
|
|
220
|
+
self._item = item
|
|
221
|
+
try:
|
|
222
|
+
return (yield)
|
|
223
|
+
finally:
|
|
224
|
+
self._item = None
|
|
225
|
+
|
|
226
|
+
def pytest_runtest_logstart(self) -> None:
|
|
227
|
+
"""Bind a file for each test attempt.
|
|
228
|
+
|
|
229
|
+
Called again on each rerun so attempts are not mixed
|
|
230
|
+
in a single file. Covers setup and teardown of all fixtures for the attempt.
|
|
231
|
+
"""
|
|
232
|
+
if self._item is None:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Plugins such as pytest-rerunfailures invoke the hook for each attempt.
|
|
236
|
+
# Unbind defensively so repeated calls do not fail the run.
|
|
237
|
+
self._handler.unbind()
|
|
238
|
+
self._targets.attach_late_loggers()
|
|
239
|
+
self._handler.bind(build_test_name(self._item))
|
|
240
|
+
|
|
241
|
+
def pytest_runtest_logfinish(self) -> None:
|
|
242
|
+
"""Route logs back to the shared file after an attempt."""
|
|
243
|
+
self._handler.unbind()
|
|
244
|
+
|
|
245
|
+
# trylast makes this the innermost wrapper: code after yield runs
|
|
246
|
+
# after all test finalizers have finished but the Allure context is still
|
|
247
|
+
# open; only then will the attachment belong to the correct test.
|
|
248
|
+
@_INNER_WRAPPER
|
|
249
|
+
def pytest_runtest_teardown(self) -> Generator[None, object, object]:
|
|
250
|
+
"""Attach logs to Allure after all fixtures have finished."""
|
|
251
|
+
try:
|
|
252
|
+
return (yield)
|
|
253
|
+
finally:
|
|
254
|
+
self._attach_to_allure()
|
|
255
|
+
|
|
256
|
+
@_FIRST
|
|
257
|
+
def pytest_runtest_setup(self, item: pytest.Item) -> None:
|
|
258
|
+
"""Write the test identifier and parameters at the start of the file."""
|
|
259
|
+
if not self._log_test_header:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
test_id = build_test_id(item)
|
|
263
|
+
params = params_of(item)
|
|
264
|
+
|
|
265
|
+
if params:
|
|
266
|
+
# json.dumps intentionally has no try/except: nonserializable
|
|
267
|
+
# parameters must produce a setup error rather than be silently dropped.
|
|
268
|
+
rendered = json.dumps(
|
|
269
|
+
params,
|
|
270
|
+
ensure_ascii=False,
|
|
271
|
+
default=str,
|
|
272
|
+
indent=self._params_indent,
|
|
273
|
+
)
|
|
274
|
+
message = f"Запуск теста id={test_id} с параметрами {rendered}"
|
|
275
|
+
else:
|
|
276
|
+
message = f"Запуск теста id={test_id}"
|
|
277
|
+
|
|
278
|
+
self._emit_directly(message)
|
|
279
|
+
|
|
280
|
+
def _attach_to_allure(self) -> None:
|
|
281
|
+
"""Attach the current test log file and fixture log file to the report."""
|
|
282
|
+
if not self._attach_allure or allure is None:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
self._handler.flush()
|
|
286
|
+
|
|
287
|
+
current = self._handler.current_file
|
|
288
|
+
if current is not None:
|
|
289
|
+
self._attach_file(current)
|
|
290
|
+
|
|
291
|
+
if self._attach_fixtures_log:
|
|
292
|
+
self._attach_file(self._handler.fixtures_file)
|
|
293
|
+
|
|
294
|
+
def shutdown(self) -> None:
|
|
295
|
+
"""Detach the handler from loggers, restore their levels, and close files."""
|
|
296
|
+
self._targets.detach()
|
|
297
|
+
self._handler.close()
|
|
298
|
+
|
|
299
|
+
def _emit_directly(self, message: str) -> None:
|
|
300
|
+
"""Send an internal record directly to the handler.
|
|
301
|
+
|
|
302
|
+
Bypass the logger tree; otherwise the header would be lost under
|
|
303
|
+
`logging_context_loggers`, restricted to user loggers.
|
|
304
|
+
"""
|
|
305
|
+
record = logging.LogRecord(
|
|
306
|
+
name=_LOGGER_NAME,
|
|
307
|
+
level=logging.INFO,
|
|
308
|
+
pathname=__file__,
|
|
309
|
+
lineno=0,
|
|
310
|
+
msg=message,
|
|
311
|
+
args=(),
|
|
312
|
+
exc_info=None,
|
|
313
|
+
)
|
|
314
|
+
self._handler.handle(record)
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _attach_file(path: Path) -> None:
|
|
318
|
+
if allure is None or not path.exists():
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
content = path.read_text(encoding="utf-8", errors="backslashreplace")
|
|
322
|
+
if not content:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
allure.attach(
|
|
326
|
+
content,
|
|
327
|
+
name=path.name,
|
|
328
|
+
attachment_type=allure.attachment_type.TEXT,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _resolve_output_dir(config: pytest.Config) -> Path | None:
|
|
333
|
+
"""Log directory, or None if the plugin is disabled."""
|
|
334
|
+
configured = str(cast("str", config.getini("logging_context_dir")))
|
|
335
|
+
if not configured:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
output_dir = Path(configured)
|
|
339
|
+
if not output_dir.is_absolute():
|
|
340
|
+
# Relative to rootdir rather than cwd, which may change during the run.
|
|
341
|
+
output_dir = config.rootpath / output_dir
|
|
342
|
+
|
|
343
|
+
launch_id = _validate_path_component(*_launch_id(config))
|
|
344
|
+
return output_dir / launch_id if launch_id else output_dir
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _launch_id(config: pytest.Config) -> tuple[str, str]:
|
|
348
|
+
"""Run identifier and the source it came from."""
|
|
349
|
+
from_option = cast("str | None", config.getoption(_LAUNCH_ID_DEST, default=""))
|
|
350
|
+
if from_option:
|
|
351
|
+
return str(from_option), LAUNCH_ID_OPTION
|
|
352
|
+
|
|
353
|
+
from_env = os.environ.get(LAUNCH_ID_ENV)
|
|
354
|
+
if from_env:
|
|
355
|
+
return from_env, LAUNCH_ID_ENV
|
|
356
|
+
|
|
357
|
+
return str(cast("str", config.getini(LAUNCH_ID_INI))), LAUNCH_ID_INI
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _worker_id(config: pytest.Config) -> str:
|
|
361
|
+
"""Pytest-xdist worker identifier, or master for a regular run."""
|
|
362
|
+
workerinput = cast("dict[str, object] | None", getattr(config, "workerinput", None))
|
|
363
|
+
if workerinput is None:
|
|
364
|
+
return "master"
|
|
365
|
+
|
|
366
|
+
return str(workerinput.get("workerid", "master"))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _validate_path_component(value: str, source: str) -> str:
|
|
370
|
+
"""Check whether the value is a single safe path component."""
|
|
371
|
+
if not value:
|
|
372
|
+
return value
|
|
373
|
+
|
|
374
|
+
is_unsafe = (
|
|
375
|
+
value in {".", ".."}
|
|
376
|
+
or any(char in value for char in ("/", "\\", "\0"))
|
|
377
|
+
or PurePath(value).is_absolute()
|
|
378
|
+
# D:run is neither absolute nor contains separators, but on Windows it switches to
|
|
379
|
+
# another drive: output_dir / "D:run" yields "D:run".
|
|
380
|
+
or bool(PureWindowsPath(value).drive)
|
|
381
|
+
)
|
|
382
|
+
if is_unsafe:
|
|
383
|
+
message = (
|
|
384
|
+
f"{source}={value!r} must be a single path component: no «/», «\\», NUL, «.», «..», "
|
|
385
|
+
f"no drive letter and not an absolute path"
|
|
386
|
+
)
|
|
387
|
+
raise pytest.UsageError(message)
|
|
388
|
+
|
|
389
|
+
return value
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _resolve_level(value: str) -> int:
|
|
393
|
+
"""Resolve a logging level from a name (DEBUG) or a number (10)."""
|
|
394
|
+
if value.isdigit():
|
|
395
|
+
return int(value)
|
|
396
|
+
|
|
397
|
+
level = logging.getLevelNamesMapping().get(value.upper())
|
|
398
|
+
if level is None:
|
|
399
|
+
message = f"logging_context_level={value!r} is not a valid logging level"
|
|
400
|
+
raise pytest.UsageError(message)
|
|
401
|
+
|
|
402
|
+
return level
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class _LoggerTargets:
|
|
406
|
+
"""Loggers with the handler attached, with their original levels."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, handler: LoggingContextHandler, logger_names: Sequence[str]) -> None:
|
|
409
|
+
"""Initialize the object.
|
|
410
|
+
|
|
411
|
+
:param handler: ``handler`` setting value.
|
|
412
|
+
:param logger_names: ``logger_names`` setting value.
|
|
413
|
+
"""
|
|
414
|
+
self._handler = handler
|
|
415
|
+
self._names = list(logger_names)
|
|
416
|
+
self._original_levels: dict[logging.Logger, int] = {}
|
|
417
|
+
|
|
418
|
+
def attach(self) -> None:
|
|
419
|
+
"""Attach the handler to the specified loggers or root."""
|
|
420
|
+
if self._names:
|
|
421
|
+
self._attach_all(_without_descendants([logging.getLogger(name) for name in self._names]))
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
root = logging.getLogger()
|
|
425
|
+
self._attach_all([root, *_non_propagating_loggers(root)])
|
|
426
|
+
|
|
427
|
+
def attach_late_loggers(self) -> None:
|
|
428
|
+
"""Catch loggers with `propagate=False` created after the run started.
|
|
429
|
+
|
|
430
|
+
These loggers do not reach root, and at `pytest_configure` time they
|
|
431
|
+
did not exist yet: test modules are imported later.
|
|
432
|
+
"""
|
|
433
|
+
if self._names:
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
self._attach_all(_non_propagating_loggers(logging.getLogger()))
|
|
437
|
+
|
|
438
|
+
def detach(self) -> None:
|
|
439
|
+
"""Detach the handler and restore original logger levels."""
|
|
440
|
+
for logger, level in self._original_levels.items():
|
|
441
|
+
logger.removeHandler(self._handler)
|
|
442
|
+
logger.setLevel(level)
|
|
443
|
+
self._original_levels.clear()
|
|
444
|
+
|
|
445
|
+
def _attach_all(self, loggers: Sequence[logging.Logger]) -> None:
|
|
446
|
+
level = self._handler.level
|
|
447
|
+
for logger in loggers:
|
|
448
|
+
if logger in self._original_levels:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
self._original_levels[logger] = logger.level
|
|
452
|
+
logger.addHandler(self._handler)
|
|
453
|
+
if logger.level == logging.NOTSET or logger.level > level:
|
|
454
|
+
logger.setLevel(level)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _without_descendants(loggers: Sequence[logging.Logger]) -> list[logging.Logger]:
|
|
458
|
+
"""Remove loggers whose records already reach a selected ancestor.
|
|
459
|
+
|
|
460
|
+
Otherwise a handler attached to both `app` and `app.http` would process one
|
|
461
|
+
record twice and duplicate the line in the file.
|
|
462
|
+
"""
|
|
463
|
+
selected = {logger.name for logger in loggers}
|
|
464
|
+
return [logger for logger in loggers if not _has_selected_ancestor(logger, selected)]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _has_selected_ancestor(logger: logging.Logger, selected: set[str]) -> bool:
|
|
468
|
+
parent = logger.parent
|
|
469
|
+
while parent is not None:
|
|
470
|
+
if not logger.propagate:
|
|
471
|
+
return False
|
|
472
|
+
if parent.name in selected:
|
|
473
|
+
return True
|
|
474
|
+
if not parent.propagate:
|
|
475
|
+
return False
|
|
476
|
+
logger, parent = parent, parent.parent
|
|
477
|
+
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _non_propagating_loggers(root: logging.Logger) -> list[logging.Logger]:
|
|
482
|
+
return [
|
|
483
|
+
logger
|
|
484
|
+
for logger in list(root.manager.loggerDict.values())
|
|
485
|
+
if isinstance(logger, logging.Logger) and not logger.propagate and logger is not root
|
|
486
|
+
]
|
logging_context/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Marker file indicating that the package supports type checking under PEP 561.
|