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,236 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager, suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Final, Self, TextIO, cast
|
|
9
|
+
|
|
10
|
+
from .naming import slugify
|
|
11
|
+
|
|
12
|
+
DEFAULT_FILE_MODE: Final = 0o600
|
|
13
|
+
DEFAULT_DIR_MODE: Final = 0o700
|
|
14
|
+
|
|
15
|
+
# O_NOFOLLOW is POSIX-only. It prevents replacing a log file with a symlink
|
|
16
|
+
# in a predictable directory.
|
|
17
|
+
_O_NOFOLLOW: Final = cast("int", getattr(os, "O_NOFOLLOW", 0))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class HandlerOptions:
|
|
22
|
+
"""Log encoding, permissions, and filename settings."""
|
|
23
|
+
|
|
24
|
+
name_suffix: str = ""
|
|
25
|
+
encoding: str = "utf-8"
|
|
26
|
+
errors: str = "backslashreplace"
|
|
27
|
+
file_mode: int = DEFAULT_FILE_MODE
|
|
28
|
+
dir_mode: int = DEFAULT_DIR_MODE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_DEFAULT_OPTIONS = HandlerOptions()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LoggingContextHandler(logging.Handler):
|
|
35
|
+
"""Write current test logs to a separate file.
|
|
36
|
+
|
|
37
|
+
Until a test is bound (`bind`), records go to the shared fixture file, which receives
|
|
38
|
+
all logs between tests and in session-scoped fixtures.
|
|
39
|
+
|
|
40
|
+
File switching uses the same lock as `emit`, so
|
|
41
|
+
background thread logging cannot write to a closed file.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
output_dir: Path,
|
|
47
|
+
fixtures_filename: str = "fixtures.log",
|
|
48
|
+
*,
|
|
49
|
+
options: HandlerOptions = _DEFAULT_OPTIONS,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize the object.
|
|
52
|
+
|
|
53
|
+
:param output_dir: ``output_dir`` setting value.
|
|
54
|
+
:param fixtures_filename: shared fixture log filename.
|
|
55
|
+
:param options: encoding, permissions, and filename suffix.
|
|
56
|
+
"""
|
|
57
|
+
super().__init__()
|
|
58
|
+
self._output_dir = output_dir
|
|
59
|
+
self._name_suffix = slugify(options.name_suffix) if options.name_suffix else ""
|
|
60
|
+
self._encoding = options.encoding
|
|
61
|
+
self._errors = options.errors
|
|
62
|
+
self._file_mode = options.file_mode
|
|
63
|
+
|
|
64
|
+
self._fixtures_path = output_dir / fixtures_filename
|
|
65
|
+
self._fixtures_stream: TextIO | None = None
|
|
66
|
+
self._test_stream: TextIO | None = None
|
|
67
|
+
self._test_path: Path | None = None
|
|
68
|
+
self._used_names: dict[str, int] = {}
|
|
69
|
+
self._closed = False
|
|
70
|
+
|
|
71
|
+
self._create_output_dir(options.dir_mode)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def output_dir(self) -> Path:
|
|
75
|
+
"""Directory where log files are written."""
|
|
76
|
+
return self._output_dir
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def fixtures_file(self) -> Path:
|
|
80
|
+
"""File used for logs outside tests."""
|
|
81
|
+
return self._fixtures_path
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def current_file(self) -> Path | None:
|
|
85
|
+
"""Current test log file, or None if no test is bound."""
|
|
86
|
+
return self._test_path
|
|
87
|
+
|
|
88
|
+
def bind(self, test_name: str) -> Path:
|
|
89
|
+
"""Route logs to a separate test file.
|
|
90
|
+
|
|
91
|
+
:param test_name: readable test name used as the filename base
|
|
92
|
+
:return: path to the created log file
|
|
93
|
+
:raises RuntimeError: if the previous test was not unbound
|
|
94
|
+
"""
|
|
95
|
+
with self._locked():
|
|
96
|
+
if self._closed:
|
|
97
|
+
message = "cannot bind a closed LoggingContextHandler"
|
|
98
|
+
raise RuntimeError(message)
|
|
99
|
+
if self._test_stream is not None:
|
|
100
|
+
message = (
|
|
101
|
+
f"logging context is already bound to «{self._test_path}»; "
|
|
102
|
+
f"call unbind() before binding «{test_name}»"
|
|
103
|
+
)
|
|
104
|
+
raise RuntimeError(message)
|
|
105
|
+
|
|
106
|
+
path = self._reserve_path(test_name)
|
|
107
|
+
self._test_stream = self._open(path, append=False)
|
|
108
|
+
self._test_path = path
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
def unbind(self) -> None:
|
|
112
|
+
"""Route logs back to the shared fixture file."""
|
|
113
|
+
with self._locked():
|
|
114
|
+
self._close_stream(self._test_stream)
|
|
115
|
+
self._test_stream = None
|
|
116
|
+
self._test_path = None
|
|
117
|
+
|
|
118
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
119
|
+
"""Write a message to the current test file or the fixture file."""
|
|
120
|
+
if self._closed:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
stream = self._test_stream or self._fixtures()
|
|
125
|
+
stream.write(self.format(record) + "\n")
|
|
126
|
+
stream.flush()
|
|
127
|
+
except Exception: # noqa: BLE001 - logging.Handler contract
|
|
128
|
+
self.handleError(record)
|
|
129
|
+
|
|
130
|
+
def flush(self) -> None:
|
|
131
|
+
"""Flush buffers of already open files.
|
|
132
|
+
|
|
133
|
+
Never open files: `logging.shutdown()` calls flush on all
|
|
134
|
+
handlers, and creating a file at this stage would be a side effect.
|
|
135
|
+
"""
|
|
136
|
+
with self._locked():
|
|
137
|
+
for stream in (self._test_stream, self._fixtures_stream):
|
|
138
|
+
if stream is not None and not stream.closed:
|
|
139
|
+
stream.flush()
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
"""Close all open files. Idempotent."""
|
|
143
|
+
with self._locked():
|
|
144
|
+
if self._closed:
|
|
145
|
+
return
|
|
146
|
+
self._closed = True
|
|
147
|
+
self._close_stream(self._test_stream)
|
|
148
|
+
self._close_stream(self._fixtures_stream)
|
|
149
|
+
self._test_stream = None
|
|
150
|
+
self._test_path = None
|
|
151
|
+
self._fixtures_stream = None
|
|
152
|
+
super().close()
|
|
153
|
+
|
|
154
|
+
def __enter__(self) -> Self:
|
|
155
|
+
"""Get a handler that closes automatically.
|
|
156
|
+
|
|
157
|
+
:return: current handler.
|
|
158
|
+
"""
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def __exit__(
|
|
162
|
+
self,
|
|
163
|
+
exc_type: type[BaseException] | None,
|
|
164
|
+
exc_value: BaseException | None,
|
|
165
|
+
traceback: TracebackType | None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Close files on context exit."""
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def _locked(self) -> Iterator[None]:
|
|
172
|
+
"""Acquire the handler lock also used by `logging` when calling `emit`."""
|
|
173
|
+
self.acquire()
|
|
174
|
+
try:
|
|
175
|
+
yield
|
|
176
|
+
finally:
|
|
177
|
+
self.release()
|
|
178
|
+
|
|
179
|
+
def _create_output_dir(self, dir_mode: int) -> None:
|
|
180
|
+
if self._output_dir.exists():
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
# Some filesystems do not support setting permissions; preserving logs matters more.
|
|
185
|
+
with suppress(OSError):
|
|
186
|
+
self._output_dir.chmod(dir_mode)
|
|
187
|
+
|
|
188
|
+
def _fixtures(self) -> TextIO:
|
|
189
|
+
"""Open the fixture file once and keep it open until `close()`.
|
|
190
|
+
|
|
191
|
+
Reopening would truncate logs already written.
|
|
192
|
+
"""
|
|
193
|
+
if self._fixtures_stream is None:
|
|
194
|
+
self._fixtures_stream = self._open(self._fixtures_path, append=False)
|
|
195
|
+
return self._fixtures_stream
|
|
196
|
+
|
|
197
|
+
def _reserve_path(self, test_name: str) -> Path:
|
|
198
|
+
"""Choose an available filename, adding a numeric suffix on collision."""
|
|
199
|
+
slug = slugify(test_name)
|
|
200
|
+
if self._name_suffix:
|
|
201
|
+
# Different processes use different files even when test names match.
|
|
202
|
+
slug = f"{slug}.{self._name_suffix}"
|
|
203
|
+
|
|
204
|
+
attempt = self._used_names.get(slug, 0) + 1
|
|
205
|
+
self._used_names[slug] = attempt
|
|
206
|
+
|
|
207
|
+
filename = f"{slug}.log" if attempt == 1 else f"{slug}.{attempt}.log"
|
|
208
|
+
path = self._output_dir / filename
|
|
209
|
+
|
|
210
|
+
# slugify excludes path separators, so escaping output_dir is impossible.
|
|
211
|
+
# This check guards against a slugify regression.
|
|
212
|
+
if path.parent != self._output_dir:
|
|
213
|
+
message = f"refusing to write log outside of {self._output_dir}: {path}"
|
|
214
|
+
raise RuntimeError(message)
|
|
215
|
+
|
|
216
|
+
return path
|
|
217
|
+
|
|
218
|
+
def _open(self, path: Path, *, append: bool) -> TextIO:
|
|
219
|
+
flags = os.O_WRONLY | os.O_CREAT | _O_NOFOLLOW
|
|
220
|
+
flags |= os.O_APPEND if append else os.O_TRUNC
|
|
221
|
+
descriptor = os.open(path, flags, self._file_mode)
|
|
222
|
+
return os.fdopen(
|
|
223
|
+
descriptor,
|
|
224
|
+
"a" if append else "w",
|
|
225
|
+
encoding=self._encoding,
|
|
226
|
+
errors=self._errors,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _close_stream(stream: TextIO | None) -> None:
|
|
231
|
+
if stream is None or stream.closed:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
stream.flush()
|
|
235
|
+
finally:
|
|
236
|
+
stream.close()
|
logging_context/hooks.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from .handler import LoggingContextHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pytest_logging_context_configure(
|
|
12
|
+
handler: LoggingContextHandler,
|
|
13
|
+
config: pytest.Config,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Configure the log handler before the run starts.
|
|
16
|
+
|
|
17
|
+
Called once after the plugin creates the handler and attaches it to
|
|
18
|
+
loggers. Extension point for formatters that cannot be expressed as an
|
|
19
|
+
ini, such as `structlog.stdlib.ProcessorFormatter` or JSON formatting.
|
|
20
|
+
|
|
21
|
+
:param handler: handler that writes logs to test files
|
|
22
|
+
:param config: pytest configuration
|
|
23
|
+
"""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import re
|
|
3
|
+
from typing import Final, Protocol, cast
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
# Anything outside this set is replaced with "_". Path separators,
|
|
8
|
+
# NUL and Windows special characters are always rejected.
|
|
9
|
+
_FORBIDDEN: Final = re.compile(r"[^A-Za-z0-9._\[\]()-]+")
|
|
10
|
+
|
|
11
|
+
# Most filesystems limit filenames to 255 bytes, not characters.
|
|
12
|
+
# Leave room for the attempt suffix and extension.
|
|
13
|
+
MAX_FILENAME_BYTES: Final = 200
|
|
14
|
+
|
|
15
|
+
_HASH_LENGTH: Final = 16
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Callspec(Protocol):
|
|
19
|
+
id: str
|
|
20
|
+
params: dict[str, object]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hash_params(params_id: str) -> str:
|
|
24
|
+
"""Calculate a stable short hash of the test parameter identifier."""
|
|
25
|
+
return hashlib.sha256(params_id.encode()).hexdigest()[-_HASH_LENGTH:]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def slugify(value: str, max_bytes: int = MAX_FILENAME_BYTES) -> str:
|
|
29
|
+
"""Convert an arbitrary string into a safe filename component.
|
|
30
|
+
|
|
31
|
+
Guarantee no path separators and a size within `max_bytes` bytes
|
|
32
|
+
in UTF-8 encoding.
|
|
33
|
+
"""
|
|
34
|
+
slug = _FORBIDDEN.sub("_", value).strip("._") or "unnamed"
|
|
35
|
+
|
|
36
|
+
encoded = slug.encode()
|
|
37
|
+
if len(encoded) <= max_bytes:
|
|
38
|
+
return slug
|
|
39
|
+
|
|
40
|
+
# Truncate by bytes rather than characters without splitting UTF-8 sequences.
|
|
41
|
+
# A hash suffix keeps truncated names distinguishable.
|
|
42
|
+
suffix = f"-{hash_params(slug)}"
|
|
43
|
+
keep = max_bytes - len(suffix.encode())
|
|
44
|
+
truncated = encoded[:keep].decode(errors="ignore")
|
|
45
|
+
return f"{truncated}{suffix}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _callspec_id(item: pytest.Item) -> str | None:
|
|
49
|
+
"""Get the identifier of the test parameter set.
|
|
50
|
+
|
|
51
|
+
`callspec` is an internal pytest.Function attribute with no official public
|
|
52
|
+
equivalent, so access it using getattr.
|
|
53
|
+
"""
|
|
54
|
+
callspec = cast("_Callspec | None", getattr(item, "callspec", None))
|
|
55
|
+
return None if callspec is None else str(callspec.id)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def params_of(item: pytest.Item) -> dict[str, object] | None:
|
|
59
|
+
"""Parameters of a parametrized test."""
|
|
60
|
+
callspec = cast("_Callspec | None", getattr(item, "callspec", None))
|
|
61
|
+
return None if callspec is None else dict(callspec.params)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_test_name(item: pytest.Item) -> str:
|
|
65
|
+
"""Readable test name for filenames, with parameters replaced by their hash."""
|
|
66
|
+
params_id = _callspec_id(item)
|
|
67
|
+
if params_id is None:
|
|
68
|
+
return slugify(item.name)
|
|
69
|
+
|
|
70
|
+
original_name = cast("str | None", getattr(item, "originalname", None)) or item.name
|
|
71
|
+
return slugify(f"{original_name}[{hash_params(params_id)}]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_test_id(item: pytest.Item) -> str:
|
|
75
|
+
"""Test identifier: nodeid with parameters replaced by their hash.
|
|
76
|
+
|
|
77
|
+
Unlike regular expression replacement, this only modifies the actual
|
|
78
|
+
test parametrization; square brackets in directory paths remain unchanged.
|
|
79
|
+
"""
|
|
80
|
+
params_id = _callspec_id(item)
|
|
81
|
+
if params_id is None:
|
|
82
|
+
return item.nodeid
|
|
83
|
+
|
|
84
|
+
suffix = f"[{params_id}]"
|
|
85
|
+
if not item.nodeid.endswith(suffix):
|
|
86
|
+
return item.nodeid
|
|
87
|
+
|
|
88
|
+
return f"{item.nodeid[: -len(suffix)]}[{hash_params(params_id)}]"
|