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.
@@ -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
+ ]
@@ -0,0 +1 @@
1
+ Marker file indicating that the package supports type checking under PEP 561.