userver
C++ Async Framework
Toggle main menu visibility
Loading...
Searching...
No Matches
logcapture.py
1
"""
2
Logcapture allows to intercepts service logs on demand with context manager.
3
4
It starts tcp server and read logs sent by server.
5
"""
6
7
import
asyncio
8
import
collections
9
import
contextlib
10
import
enum
11
import
logging
12
import
typing
13
14
from
testsuite.utils
import
callinfo, net, traceback
15
16
logger = logging.getLogger(__name__)
17
18
19
class
BaseError
(Exception):
20
pass
21
22
23
class
IncorrectUsageError
(
BaseError
):
24
"""Incorrect usage error."""
25
26
27
class
ClientConnectTimeoutError
(
BaseError
):
28
pass
29
30
31
class
TimeoutError
(
BaseError
):
32
pass
33
34
35
class
LogLevel
(enum.IntEnum):
36
"""
37
Represents log level as IntEnum, which supports comparison.
38
39
Available levels are: TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL, NONE
40
"""
41
42
TRACE = 0
43
DEBUG = 1
44
INFO = 2
45
WARNING = 3
46
ERROR = 4
47
CRITICAL = 5
48
NONE = 6
49
50
@classmethod
51
def
from_string
(cls, level: str) ->
'LogLevel'
:
52
"""Parse log level from the string."""
53
return
cls[level.upper()]
54
55
56
class
CapturedLogs
:
57
def
__init__(self, *, log_level: LogLevel) ->
None
:
58
self.
_log_level
= log_level
59
self.
_logs
: list[dict] = []
60
self.
_subscribers
= []
61
self.
_closed
=
False
62
63
@property
64
def
log_level(self):
65
return
self.
_log_level
66
67
def
is_closed(self):
68
return
self.
_closed
69
70
def
close(self):
71
self.
_closed
=
True
72
73
async def
publish(self, row: dict) ->
None
:
74
self.
_logs
.append(row)
75
for
query, callback
in
self.
_subscribers
:
76
if
_match_entry(row, query):
77
await callback(**row)
78
79
def
subscribe(self, query: dict, decorated):
80
self.
_subscribers
.append((query, decorated))
81
82
def
__iter__(self) -> typing.Iterator[dict]:
83
return
iter(self.
_logs
)
84
85
86
class
Capture
:
87
def
__init__(self, logs: CapturedLogs):
88
self.
_logs
= logs
89
90
def
select
(self, **query) -> list[dict]:
91
"""Select logs matching query.
92
93
Could only be used after capture contextmanager block.
94
95
.. code-block:: python
96
97
async with logcapture_server.capture() as capture:
98
...
99
records = capture.select(text='Message to capture')
100
"""
101
if
not
self.
_logs
.is_closed():
102
raise
IncorrectUsageError
(
103
'select() is only supported for closed captures\n'
104
'Please move select() after context manager body'
,
105
)
106
level = query.get(
'level'
)
107
if
level:
108
log_level = LogLevel[level]
109
if
log_level.value < self.
_logs
.log_level.value:
110
raise
IncorrectUsageError
(
111
f
'Requested log level={log_level.name} is lower than service log level {self._logs.log_level.name}'
,
112
)
113
result = []
114
for
row
in
self.
_logs
:
115
if
_match_entry(row, query):
116
result.append(row)
117
return
result
118
119
def
subscribe
(self, **query):
120
"""Subscribe to records matching `query`. Returns decorator function.
121
`subscribe()` may only be used within `capture()` block. Callqueue is returned.
122
123
.. code-block:: python
124
125
async with logcapture_server.capture() as capture:
126
@capture.subscribe(text='Message to capture')
127
def log_event(link, **other):
128
...
129
...
130
assert log_event.wait_call()
131
"""
132
if
self.
_logs
.is_closed():
133
raise
IncorrectUsageError
(
134
'subscribe() is not supported for closed captures\nPlease move subscribe() into context manager body'
,
135
)
136
137
def
decorator(func):
138
decorated = callinfo.acallqueue(func)
139
self.
_logs
.
subscribe
(query, decorated)
140
return
decorated
141
142
return
decorator
143
144
145
class
CaptureServer
:
146
_capture: CapturedLogs |
None
147
148
def
__init__
(
149
self,
150
*,
151
log_level: LogLevel,
152
parse_line: collections.abc.Callable[[bytes], dict],
153
):
154
"""Capture server."""
155
self.
_log_level
= log_level
156
self.
_client_cond
= asyncio.Condition()
157
self.
_capture
=
None
158
self.
_tasks
= []
159
self.
_parse_line
= parse_line
160
self.
_started
=
False
161
self.
_socknames
= []
162
163
@property
164
def
default_log_level
(self) -> LogLevel:
165
"""Returns default log level specified on object creation."""
166
return
self.
_log_level
167
168
def
getsocknames
(self) -> list[tuple]:
169
"""Return list of server socket names."""
170
return
self.
_socknames
171
172
@contextlib.asynccontextmanager
173
async def
start
(
174
self, host='localhost', port=0, **kwargs
175
) -> typing.AsyncIterator['CaptureServer']:
176
"""Starts capture logs asyncio server.
177
178
Arguments are directly passed to `asyncio.start_server`. Server could be started
179
only once. Capture server is returned. Server is closed when contextmanager
180
is finished.
181
"""
182
if
self.
_started
:
183
raise
IncorrectUsageError
(
'Service was already started'
)
184
185
sockets = net.bind_socket_multiple(host, port)
186
server = await net.start_multiple_servers(
187
self.
_handle_client
, sockets, **kwargs
188
)
189
self.
_started
=
True
190
self.
_socknames
= [sock.getsockname()
for
sock
in
server.sockets]
191
logger.debug(
'Logcapture server bound to %r'
, self.
_socknames
)
192
try
:
193
yield
self
194
finally
:
195
server.close()
196
await server.wait_closed()
197
198
async def
wait_for_client
(self, timeout: float = 10.0):
199
"""Waits for logserver client to connect."""
200
201
async def
waiter():
202
async
with
self.
_client_cond
:
203
await self.
_client_cond
.wait_for(
lambda
: self.
_tasks
)
204
205
logger.debug(
'Waiting for logcapture client to connect...'
)
206
try
:
207
await asyncio.wait_for(waiter(), timeout=timeout)
208
except
TimeoutError:
209
raise
ClientConnectTimeoutError
(
210
'Timedout while waiting for logcapture client to connect'
,
211
)
212
213
async def
_handle_client(self, reader, writer):
214
logger.debug(
'logcapture client connected'
)
215
216
async def
log_reader(capture: CapturedLogs):
217
with
contextlib.closing(writer):
218
try
:
219
async
for
line
in
reader:
220
row = self.
_parse_line
(line)
221
await capture.publish(row)
222
except
Exception:
223
async
for
line
in
reader:
224
# wait for data transfer to finish
225
pass
226
raise
227
await writer.wait_closed()
228
229
if
not
self.
_capture
:
230
writer.close()
231
await writer.wait_closed()
232
else
:
233
self.
_tasks
.append(asyncio.create_task(log_reader(self.
_capture
)))
234
async
with
self.
_client_cond
:
235
self.
_client_cond
.notify_all()
236
237
@contextlib.asynccontextmanager
238
async def
capture
(
239
self,
240
*,
241
log_level: LogLevel |
None
=
None
,
242
timeout: float = 10.0,
243
) -> typing.AsyncIterator[Capture]:
244
"""
245
Starts logs capture. Returns `Capture` object.
246
"""
247
if
self.
_capture
:
248
yield
self.
_capture
249
return
250
251
self.
_capture
=
CapturedLogs
(log_level=log_level
or
self.
_log_level
)
252
try
:
253
yield
Capture
(self.
_capture
)
254
finally
:
255
self.
_capture
.close()
256
self.
_capture
=
None
257
if
self.
_tasks
:
258
_, pending = await asyncio.wait(self.
_tasks
, timeout=timeout)
259
self.
_tasks
= []
260
if
pending:
261
raise
TimeoutError
(
262
'Timeout while waiting for capture task to finish'
,
263
)
264
265
266
def
_match_entry(row: dict, query: dict) -> bool:
267
for
key, value
in
query.items():
268
if
row.get(key) != value:
269
return
False
270
return
True
271
272
273
__tracebackhide__ = traceback.hide(BaseError, FileNotFoundError)
en
testsuite
logcapture.py
Generated on
for userver by
Doxygen
1.17.0