userver: /data/code/userver/testsuite/pytest_plugins/pytest_userver/plugins/logging.py Source File
Loading...
Searching...
No Matches
logging.py
1import asyncio
2import io
3import logging
4import pathlib
5import sys
6import threading
7import time
8import typing
9
10import pytest
11
12from pytest_userver.utils import colorize
13
14logger = logging.getLogger(__name__)
15
16
17class LogFile:
18 def __init__(self, path: pathlib.Path):
19 self.path = path
20 self.position = 0
21
22 def update_position(self):
23 try:
24 st = self.path.stat()
25 except FileNotFoundError:
26 pos = 0
27 else:
28 pos = st.st_size
29 self.position = pos
30 return pos
31
32 def readlines(
33 self,
34 eof_handler: typing.Optional[typing.Callable[[], bool]] = None,
35 limit_position: bool = False,
36 ):
37 if limit_position:
38 max_position = self.path.stat().st_size
39 else:
40 max_position = None
41 first_skipped = False
42 for line, position in _raw_line_reader(
43 self.path, self.position, eof_handler=eof_handler,
44 ):
45 # userver does not give any gurantees about log file encoding
46 line = line.decode(encoding='utf-8', errors='backslashreplace')
47 if not first_skipped:
48 first_skipped = True
49 if not line.startswith('tskv\t'):
50 continue
51 if not line.endswith('\n'):
52 continue
53 self.position = position
54 yield line
55 if max_position is not None and position >= max_position:
56 break
57
58
60 def __init__(self, *, colorize_factory, delay: float = 0.05):
61 self._colorize_factory = colorize_factory
62 self._threads = {}
63 self._exiting = False
64 self._delay = delay
65
66 def register_logfile(self, path: pathlib.Path):
67 if path in self._threads:
68 return
69 thread = threading.Thread(target=self._logreader_thread, args=(path,))
70 self._threads[path] = thread
71 thread.start()
72
73 def join(self, timeout: float = 10):
74 self._exiting = True
75 for thread in self._threads.values():
76 thread.join(timeout=timeout)
77
78 def _logreader_thread(self, path: pathlib.Path):
79 # wait for file to appear
80 while not path.exists():
81 if self._eof_handler():
82 return
83 colorizer = self._colorize_factory()
84 logfile = LogFile(path)
85 for line in logfile.readlines(eof_handler=self._eof_handler):
86 line = line.rstrip('\r\n')
87 line = colorizer(line)
88 if line:
89 self._write_logline(line)
90
91 def _write_logline(self, line: str):
92 print(line, file=sys.stderr)
93
94 def _eof_handler(self) -> bool:
95 if self._exiting:
96 return True
97 time.sleep(self._delay)
98 return False
99
100
102 _live_logs = None
103
104 def __init__(self, *, colorize_factory, config):
105 self._colorize_factory = colorize_factory
106 self._config = config
107 self._logs = {}
108 self._flushers = []
109
110 def pytest_sessionstart(self, session):
111 if _is_live_logs_enabled(self._config):
113 colorize_factory=self._colorize_factory,
114 )
115 else:
116 self._live_logs = None
117
118 def pytest_sessionfinish(self, session):
119 if self._live_logs:
120 self._live_logs.join()
121
122 def pytest_runtest_setup(self, item):
123 self._flushers.clear()
124 self.update_position()
125
126 @pytest.hookimpl(wrapper=True, tryfirst=True)
127 def pytest_runtest_makereport(self, item, call):
128 report = yield
129 if report.failed and not self._live_logs:
130 self._userver_report_attach(report)
131 return report
132
133 def update_position(self):
134 for logfile in self._logs.values():
135 logfile.update_position()
136
137 def register_flusher(self, func):
138 self._flushers.append(func)
139
140 def register_logfile(self, path: pathlib.Path, title: str):
141 logger.info('Watching service log file: %s', path)
142 logfile = LogFile(path)
143 self._logs[path, title] = logfile
144 if self._live_logs:
145 self._live_logs.register_logfile(path)
146
147 def _userver_report_attach(self, report):
148 self._run_flushers()
149 for (_, title), logfile in self._logs.items():
150 self._userver_report_attach_log(logfile, report, title)
151
152 def _userver_report_attach_log(self, logfile: LogFile, report, title):
153 log = io.StringIO()
154 colorizer = self._colorize_factory()
155 for line in logfile.readlines(limit_position=True):
156 line = line.rstrip('\r\n')
157 line = colorizer(line)
158 if line:
159 log.write(line)
160 log.write('\n')
161 value = log.getvalue()
162 if value:
163 report.sections.append((f'Captured {title} {report.when}', value))
164
165 def _run_flushers(self):
166 loop = asyncio.get_event_loop()
167 for flusher in self._flushers:
168 loop.run_until_complete(flusher())
169
170
171@pytest.fixture(scope='session')
172def service_logfile_path(
173 pytestconfig, service_tmpdir: pathlib.Path,
174) -> typing.Optional[pathlib.Path]:
175 """
176 Holds optional service logfile path. You may want to override this
177 in your service.
178
179 By default returns value of --service-logs-file option or creates
180 temporary file.
181 """
182 if pytestconfig.option.service_logs_file:
183 return pytestconfig.option.service_logs_file
184 return service_tmpdir / 'service.log'
185
186
187@pytest.fixture(scope='session')
188def _service_logfile_path(
189 userver_register_logfile,
190 service_logfile_path: typing.Optional[pathlib.Path],
191) -> typing.Optional[pathlib.Path]:
192 if not service_logfile_path:
193 return None
194 userver_register_logfile(
195 service_logfile_path, title='userver/log', truncate=True,
196 )
197 return service_logfile_path
198
199
200@pytest.fixture(scope='session')
201def userver_register_logfile(_userver_logging_plugin: UserverLoggingPlugin):
202 """
203 Register logfile. Registered logfile is monitored in case of test failure
204 and its contents is attached to pytest report.
205
206 :param path: pathlib.Path corresponding to log file
207 :param title: title to be used in pytest report
208 :param truncate: file is truncated if True
209
210 ```python
211 def register_logfile(
212 path: pathlib.Path, *, title: str, truncate: bool = False,
213 ) -> None:
214 ```
215 """
216
217 def do_truncate(path):
218 with path.open('wb+') as fp:
219 fp.truncate()
220
221 def register_logfile(
222 path: pathlib.Path, *, title: str, truncate: bool = False,
223 ) -> None:
224 if truncate:
225 do_truncate(path)
226 _userver_logging_plugin.register_logfile(path, title)
227 return path
228
229 return register_logfile
230
231
232@pytest.fixture(scope='session')
233def _userver_logging_plugin(pytestconfig) -> UserverLoggingPlugin:
234 return pytestconfig.pluginmanager.get_plugin('userver_logging')
235
236
237def pytest_configure(config):
238 pretty_logs = config.option.service_logs_pretty
239 colors_enabled = _should_enable_color(config)
240 verbose = pretty_logs == 'verbose'
241
242 def colorize_factory():
243 if pretty_logs:
244 colorizer = colorize.Colorizer(
245 verbose=verbose, colors_enabled=colors_enabled,
246 )
247 return colorizer.colorize_line
248
249 def handle_line(line):
250 return line
251
252 return handle_line
253
254 plugin = UserverLoggingPlugin(
255 colorize_factory=colorize_factory, config=config,
256 )
257 config.pluginmanager.register(plugin, 'userver_logging')
258
259
260def pytest_report_header(config):
261 headers = []
262 if config.option.service_logs_file:
263 headers.append(f'servicelogs: {config.option.service_logs_file}')
264 return headers
265
266
267def pytest_terminal_summary(terminalreporter, config) -> None:
268 logfile = config.option.service_logs_file
269 if logfile:
270 terminalreporter.ensure_newline()
271 terminalreporter.section('Service logs', sep='-', blue=True, bold=True)
272 terminalreporter.write_sep('-', f'service log file: {logfile}')
273
274
275def _should_enable_color(pytestconfig) -> bool:
276 option = getattr(pytestconfig.option, 'color', 'no')
277 if option == 'yes':
278 return True
279 if option == 'auto':
280 return sys.stderr.isatty()
281 return False
282
283
284def _raw_line_reader(
285 path: pathlib.Path,
286 position: int = 0,
287 eof_handler: typing.Optional[typing.Callable[[], bool]] = None,
288) -> int:
289 with path.open('rb') as fp:
290 position = fp.seek(position)
291 while True:
292 partial = None
293 for line in fp:
294 if partial:
295 line = partial + line
296 partial = None
297 if line.endswith(b'\n'):
298 position += len(line)
299 yield line, position
300 else:
301 partial = line
302 if not eof_handler:
303 break
304 if eof_handler():
305 break
306
307
308def _is_live_logs_enabled(config):
309 if not config.option.service_live_logs_disable:
310 return bool(
311 config.option.capture == 'no'
312 and config.option.showcapture in ('all', 'log'),
313 )
314 return False