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,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()
@@ -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)}]"