userver: en/testsuite/daemons/pytest_plugin.py Source File
Loading...
Searching...
No Matches
pytest_plugin.py
1import contextlib
2import inspect
3import itertools
4import signal
5import subprocess
6import typing
7import warnings
8from collections.abc import AsyncGenerator, Callable, Sequence
9from typing import Any, AsyncContextManager
10
11import aiohttp
12import pytest
13
14from testsuite import types
15from testsuite._internal import fixture_types
16
17from . import service_client, service_daemon
18from .classes import DaemonInstance
19from .spawn import __tracebackhide__ # noqa: F401
20
21SHUTDOWN_SIGNALS = {
22 'SIGINT': signal.SIGINT,
23 'SIGKILL': signal.SIGKILL,
24 'SIGQUIT': signal.SIGQUIT,
25 'SIGTERM': signal.SIGTERM,
26}
27
28
30 def __init__(
31 self,
32 name: str,
33 spawn: Callable,
34 *,
35 multiple: bool = False,
36 ) -> None:
37 self.name = name
38 self._spawn = spawn
39 self.multiple = multiple
40
41 async def spawn(self) -> 'DaemonInstance':
42 manager = self._spawn()
43 # For backward compatibility with older spawners
44 if inspect.iscoroutine(manager):
45 warnings.warn(
46 f'Please rewrite your spawner into async context manager {self._spawn}',
47 PendingDeprecationWarning,
48 )
49 manager = await manager
50 process = await manager.__aenter__()
51 return DaemonInstance(manager, process)
52
53
55 _cells: dict[str, tuple[_DaemonScope, DaemonInstance]]
56
57 def __init__(self) -> None:
58 self._cells = {}
59
60 async def aclose(self, skip_multiple=False) -> None:
61 cells_left = {}
62 for name, (scope, daemon) in self._cells.items():
63 if skip_multiple and scope.multiple:
64 cells_left[name] = scope, daemon
65 else:
66 await self._close_daemon(daemon)
67 self._cells = cells_left
68
69 @contextlib.asynccontextmanager
70 async def scope(
71 self,
72 name,
73 spawn,
74 *,
75 multiple: bool = False,
76 ) -> AsyncGenerator[_DaemonScope, None]:
77 """
78 Creates new scope evicting previous daemon.
79
80 :param name: scope identifier.
81 :param spawn: spawner instance.
82 :param multiple: do not fail when this scope is requested with others.
83 """
84 scope = _DaemonScope(name, spawn, multiple=multiple)
85 try:
86 yield scope
87 finally:
88 await self._cleanup_cell(name)
89
90 async def request(self, scope: _DaemonScope) -> DaemonInstance:
91 if scope.name in self._cells:
92 _, daemon = self._cells[scope.name]
93 if daemon.process is None:
94 return daemon
95 if daemon.process.poll() is None:
96 return daemon
97 await self.aclose(skip_multiple=True)
98 daemon = await scope.spawn()
99 self._cells[scope.name] = scope, daemon
100 return daemon
101
102 def has_running_daemons(self) -> bool:
103 for _, daemon in self._cells.values():
104 if daemon.process and daemon.process.poll() is None:
105 return True
106 return False
107
108 async def _cleanup_cell(self, name):
109 if name not in self._cells:
110 return
111 scope, daemon = self._cells.pop(name)
112 await self._close_daemon(daemon)
113
114 async def _close_daemon(self, daemon: DaemonInstance):
115 await daemon.aclose()
116
117
118class EnsureDaemonStartedFixture(typing.Protocol):
119 """Fixture that starts requested service."""
120
121 async def __call__(self, scope: _DaemonScope) -> DaemonInstance: ...
122
123
124class ServiceSpawnerFactory(typing.Protocol):
126 self,
127 args: Sequence[str],
128 *,
129 base_command: Sequence[str] | None = None,
130 env: dict[str, str] | None = None,
131 poll_retries: int = service_daemon.POLL_RETRIES,
132 ping_url: str | None = None,
133 ping_request_timeout: float = service_daemon.PING_REQUEST_TIMEOUT,
134 ping_response_codes: tuple[int] = service_daemon.PING_RESPONSE_CODES,
135 health_check: service_daemon.HealthCheckType | None = None,
136 subprocess_spawner: Callable[..., subprocess.Popen] | None = None,
137 subprocess_options: dict[str, Any] | None = None,
138 setup_service: Callable[[subprocess.Popen], None] | None = None,
139 shutdown_signal: int | None = None,
140 stdout_handler=None,
141 stderr_handler=None,
142 ):
143 """Creates service spawner asynccontextmanager factory.
144
145 :param args: command arguments
146 :param base_command: Arguments to be prepended to ``args``.
147 :param env: Environment variables dictionary.
148 :param poll_retries: Number of tries for service health check
149 :param ping_url: service health check url, service is considered up
150 when 200 received.
151 :param ping_request_timeout: Timeout for ping_url request
152 :param ping_response_codes: HTTP resopnse codes tuple meaning that
153 service is up and running.
154 :param health_check: Async function to check service is running.
155 :param subprocess_spawner: callable with `subprocess.Popen` interface.
156 :param subprocess_options: Custom subprocess options.
157 :param setup_service: Function to be called right after service
158 is started.
159 :param shutdown_signal: Signal used to stop running services.
160 :returns: Return asynccontextmanager factory that might be used
161 within ``register_daemon_scope`` fixture.
162 """
163
164
165class CreateDaemonScope(typing.Protocol):
166 """Create daemon scope for daemon with command to start."""
167
169 self,
170 *,
171 args: Sequence[str],
172 ping_url: str | None = None,
173 name: str | None = None,
174 base_command: Sequence | None = None,
175 env: dict[str, str] | None = None,
176 poll_retries: int = service_daemon.POLL_RETRIES,
177 ping_request_timeout: float = service_daemon.PING_REQUEST_TIMEOUT,
178 ping_response_codes: tuple[int] = service_daemon.PING_RESPONSE_CODES,
179 health_check: service_daemon.HealthCheckType | None = None,
180 subprocess_options: dict[str, Any] | None = None,
181 setup_service: Callable[[subprocess.Popen], None] | None = None,
182 shutdown_signal: int | None = None,
183 stdout_handler=None,
184 stderr_handler=None,
185 multiple=True,
186 ) -> AsyncContextManager[_DaemonScope]:
187 """
188 :param args: command arguments
189 :param base_command: Arguments to be prepended to ``args``.
190 :param env: Environment variables dictionary.
191 :param poll_retries: Number of tries for service health check
192 :param ping_url: service health check url, service is considered up
193 when 200 received.
194 :param ping_request_timeout: Timeout for ping_url request
195 :param ping_response_codes: HTTP resopnse codes tuple meaning that
196 service is up and running.
197 :param health_check: Async function to check service is running.
198 :param subprocess_options: Custom subprocess options.
199 :param setup_service: Function to be called right after service
200 is started.
201 :param shutdown_signal: Signal used to stop running services.
202 :param multiple: do not fail when this scope is requested with others.
203 :returns: Returns internal daemon scope instance to be used with
204 ``ensure_daemon_started`` fixture.
205 """
206
207
208class CreateServiceClientFixture(typing.Protocol):
209 """Creates service client instance.
210
211 Example:
212
213 .. code-block:: python
214
215 def my_client(create_service_client):
216 return create_service_client('http://localhost:9999/')
217 """
218
220 self,
221 base_url: str,
222 *,
223 client_class=service_client.Client,
224 **kwargs,
225 ):
226 """
227 :param base_url: base url for http client
228 :param client_class: client class to use
229 :returns: ``client_class`` instance
230 """
231
232
233@pytest.fixture
234def ensure_daemon_started(
235 _global_daemon_store: _DaemonStore, _testsuite_suspend_capture, pytestconfig
236) -> EnsureDaemonStartedFixture:
237 requests = set()
238
239 async def ensure_daemon_started(scope: _DaemonScope) -> DaemonInstance:
240 if not scope.multiple:
241 requests.add(scope.name)
242 if len(requests) > 1:
243 pytest.fail(f'Test requested multiple daemons: {requests!r}')
244
245 if pytestconfig.option.service_wait:
246 with _testsuite_suspend_capture():
247 return await _global_daemon_store.request(scope)
248 return await _global_daemon_store.request(scope)
249
250 return ensure_daemon_started
251
252
253@pytest.fixture(scope='session')
254def service_spawner_factory(
255 pytestconfig: Any,
256 service_client_session_factory: Any,
257 wait_service_started: Any,
258) -> ServiceSpawnerFactory:
259 def service_spawner_factory(
260 args: Sequence[str],
261 *,
262 base_command: Sequence[str] | None = None,
263 env: dict[str, str] | None = None,
264 poll_retries: int = service_daemon.POLL_RETRIES,
265 ping_url: str | None = None,
266 ping_request_timeout: float = service_daemon.PING_REQUEST_TIMEOUT,
267 ping_response_codes: tuple[int] = service_daemon.PING_RESPONSE_CODES,
268 health_check: service_daemon.HealthCheckType | None = None,
269 subprocess_spawner: Callable[..., subprocess.Popen] | None = None,
270 subprocess_options: dict[str, Any] | None = None,
271 setup_service: Callable[[subprocess.Popen], None] | None = None,
272 shutdown_signal: int | None = None,
273 stdout_handler=None,
274 stderr_handler=None,
275 ):
276 shutdown_timeout = pytestconfig.option.service_shutdown_timeout
277 if shutdown_signal is None:
278 shutdown_signal = SHUTDOWN_SIGNALS[
279 pytestconfig.option.service_shutdown_signal
280 ]
281
282 health_check = service_daemon.make_health_check(
283 ping_url=ping_url,
284 ping_request_timeout=ping_request_timeout,
285 ping_response_codes=ping_response_codes,
286 health_check=health_check,
287 )
288
289 command_args = _build_command_args(args, base_command)
290
291 @contextlib.asynccontextmanager
292 async def spawn():
293 if pytestconfig.option.service_wait:
294 manager = wait_service_started(
295 args=command_args,
296 health_check=health_check,
297 )
298 elif pytestconfig.option.service_disable:
299 manager = service_daemon.start_dummy_process()
300 else:
301 manager = service_daemon.start(
302 args=command_args,
303 env=env,
304 shutdown_signal=shutdown_signal,
305 shutdown_timeout=shutdown_timeout,
306 poll_retries=poll_retries,
307 health_check=health_check,
308 session_factory=service_client_session_factory,
309 subprocess_options=subprocess_options,
310 setup_service=setup_service,
311 subprocess_spawner=subprocess_spawner,
312 stdout_handler=stdout_handler,
313 stderr_handler=stderr_handler,
314 )
315 async with manager as process:
316 yield process
317
318 return spawn
319
320 return service_spawner_factory
321
322
323@pytest.fixture(scope='session')
324def service_spawner(service_spawner_factory):
325 def service_spawner(*args, **kwargs):
326 factory = service_spawner_factory(*args, **kwargs)
327 warnings.warn(
328 'service_spawner() fixture is deprecated, '
329 'use service_spawner_factory()',
330 PendingDeprecationWarning,
331 )
332
333 async def spawner():
334 return factory()
335
336 return spawner
337
338 return service_spawner
339
340
341@pytest.fixture(scope='session')
343 _global_daemon_store: _DaemonStore,
344 service_spawner_factory: ServiceSpawnerFactory,
345) -> CreateDaemonScope:
346 """Create daemon scope for daemon with command to start.
347
348 @ingroup userver_testsuite_fixtures
349 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/daemons/pytest_plugin.py#L342)
350 """
351
353 *,
354 args: Sequence[str],
355 ping_url: str | None = None,
356 name: str | None = None,
357 base_command: Sequence | None = None,
358 env: dict[str, str] | None = None,
359 poll_retries: int = service_daemon.POLL_RETRIES,
360 ping_request_timeout: float = service_daemon.PING_REQUEST_TIMEOUT,
361 ping_response_codes: tuple[int] = service_daemon.PING_RESPONSE_CODES,
362 health_check: service_daemon.HealthCheckType | None = None,
363 subprocess_options: dict[str, Any] | None = None,
364 setup_service: Callable[[subprocess.Popen], None] | None = None,
365 shutdown_signal: int | None = None,
366 stdout_handler=None,
367 stderr_handler=None,
368 multiple=True,
369 ) -> AsyncContextManager[_DaemonScope]:
370 """
371 :param args: command arguments
372 :param base_command: Arguments to be prepended to ``args``.
373 :param env: Environment variables dictionary.
374 :param poll_retries: Number of tries for service health check
375 :param ping_url: service health check url, service is considered up
376 when 200 received.
377 :param ping_request_timeout: Timeout for ping_url request
378 :param ping_response_codes: HTTP resopnse codes tuple meaning that
379 service is up and running.
380 :param health_check: Async function to check service is running.
381 :param subprocess_options: Custom subprocess options.
382 :param setup_service: Function to be called right after service
383 is started.
384 :param shutdown_signal: Signal used to stop running services.
385 :param multiple: do not fail when this scope is requested with others.
386 :returns: Returns internal daemon scope instance to be used with
387 ``ensure_daemon_started`` fixture.
388 """
389 if name is None:
390 name = ' '.join(args)
391 return _global_daemon_store.scope(
392 name=name,
393 spawn=service_spawner_factory(
394 args=args,
395 base_command=base_command,
396 env=env,
397 poll_retries=poll_retries,
398 ping_url=ping_url,
399 ping_request_timeout=ping_request_timeout,
400 ping_response_codes=ping_response_codes,
401 health_check=health_check,
402 subprocess_options=subprocess_options,
403 setup_service=setup_service,
404 shutdown_signal=shutdown_signal,
405 stdout_handler=stdout_handler,
406 stderr_handler=stderr_handler,
407 ),
408 multiple=multiple,
409 )
410
411 return create_daemon_scope
412
413
414@pytest.fixture
415def create_service_client(
416 service_client_default_headers: dict[str, str],
417 service_client_options: dict[str, Any],
418) -> CreateServiceClientFixture:
419 def create_service_client(
420 base_url: str,
421 *,
422 client_class=service_client.Client,
423 **kwargs,
424 ):
425 """
426 :param base_url: base url for http client
427 :param client_class: client class to use
428 :returns: ``client_class`` instance
429 """
430 return client_class(
431 base_url,
432 headers=service_client_default_headers,
433 **service_client_options,
434 **kwargs,
435 )
436
437 return create_service_client
438
439
440@pytest.fixture(scope='session')
441def wait_service_started(pytestconfig, service_client_session_factory):
442 reporter = pytestconfig.pluginmanager.getplugin('terminalreporter')
443
444 @contextlib.asynccontextmanager
445 async def waiter(*, args, health_check):
446 await service_daemon.service_wait(
447 args=args,
448 reporter=reporter,
449 health_check=health_check,
450 session_factory=service_client_session_factory,
451 )
452 yield None
453
454 return waiter
455
456
457def pytest_addoption(parser):
458 group = parser.getgroup('services')
459 group.addoption(
460 '--service-timeout',
461 metavar='TIMEOUT',
462 help=(
463 'Service client timeout in seconds. 0 means no timeout. '
464 'Default is %(default)s'
465 ),
466 default=120.0,
467 type=float,
468 )
469 group.addoption(
470 '--service-disable',
471 action='store_true',
472 help='Do not start service daemon from testsuite',
473 )
474 group.addoption(
475 '--service-wait',
476 action='store_true',
477 help='Wait for service to start outside of testsuite itself, e.g. gdb',
478 )
479 group.addoption(
480 '--service-shutdown-timeout',
481 help='Service shutdown timeout in seconds. Default is %(default)s',
482 default=120.0,
483 type=float,
484 )
485 group.addoption(
486 '--service-shutdown-signal',
487 help='Service shutdown signal. Default is %(default)s',
488 default='SIGINT',
489 choices=sorted(SHUTDOWN_SIGNALS.keys()),
490 )
491
492
493@pytest.fixture(scope='session')
494def register_daemon_scope(_global_daemon_store: _DaemonStore):
495 """Context manager that registers service process session.
496
497 Yields daemon scope instance.
498
499 @param name service name
500 @param spawn asynccontextmanager service factory
501
502 @ingroup userver_testsuite_fixtures
503 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/daemons/pytest_plugin.py#L490)
504 """
505 return _global_daemon_store.scope
506
507
508@pytest.fixture(scope='session')
509def service_client_session_factory() -> service_daemon.ClientSessionFactory:
510 def make_session(**kwargs):
511 return aiohttp.ClientSession(**kwargs)
512
513 return make_session
514
515
516@pytest.fixture
517async def service_client_session(
518 service_client_session_factory,
519) -> types.AsyncYieldFixture[aiohttp.ClientSession]:
520 async with service_client_session_factory() as session:
521 yield session
522
523
524@pytest.fixture
525def service_client_default_headers() -> dict[str, str]:
526 """Default service client headers.
527
528 Fill free to override in your conftest.py
529
530 @ingroup userver_testsuite_fixtures
531 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/daemons/pytest_plugin.py#L518)
532 """
533 return {}
534
535
536@pytest.fixture
538 pytestconfig,
539 service_client_session: aiohttp.ClientSession,
541) -> types.YieldFixture[dict[str, Any]]:
542 """Returns service client options dictionary.
543
544 @ingroup userver_testsuite_fixtures
545 Part of the [yandex-taxi-testsuite](https://github.com/yandex/yandex-taxi-testsuite/blob/develop/testsuite/daemons/pytest_plugin.py#L527)
546 """
547 yield {
548 'session': service_client_session,
549 'timeout': pytestconfig.option.service_timeout or None,
550 'span_id_header': mockserver.span_id_header,
551 }
552
553
554@pytest.fixture(scope='session')
555async def _global_daemon_store():
556 store = _DaemonStore()
557 async with contextlib.aclosing(store):
558 yield store
559
560
561@pytest.fixture(scope='session')
562def _testsuite_suspend_capture(pytestconfig):
563 capmanager = pytestconfig.pluginmanager.getplugin('capturemanager')
564
565 @contextlib.contextmanager
566 def suspend():
567 try:
568 capmanager.suspend_global_capture()
569 yield
570 finally:
571 capmanager.resume_global_capture()
572
573 return suspend
574
575
576def _build_command_args(
577 args: Sequence,
578 base_command: Sequence | None,
579) -> tuple[str, ...]:
580 return tuple(str(arg) for arg in itertools.chain(base_command or (), args))