userver: /data/code/userver/testsuite/pytest_plugins/pytest_userver/plugins/service.py Source File
Loading...
Searching...
No Matches
service.py
1"""
2Start the service in testsuite.
3"""
4
5# pylint: disable=redefined-outer-name
6from collections.abc import Awaitable
7from collections.abc import Callable
8from collections.abc import Iterable
9import logging
10import pathlib
11import time
12import typing
13from typing import Any
14
15import pytest
16
17from testsuite.utils import url_util
18
19from pytest_userver.utils import net
20
21logger = logging.getLogger(__name__)
22
23
24def pytest_addoption(parser) -> None:
25 group = parser.getgroup('userver')
26 group.addoption(
27 '--service-logs-file',
28 type=pathlib.Path,
29 help='Write service output to specified file',
30 )
31 group.addoption(
32 '--service-logs-pretty',
33 action='store_true',
34 help='Enable pretty print and colorize service logs',
35 )
36 group.addoption(
37 '--service-logs-pretty-verbose',
38 dest='service_logs_pretty',
39 action='store_const',
40 const='verbose',
41 help='Enable pretty print and colorize service logs in verbose mode',
42 )
43 group.addoption(
44 '--service-logs-pretty-disable',
45 action='store_false',
46 dest='service_logs_pretty',
47 help='Disable pretty print and colorize service logs',
48 )
49
50
51@pytest.fixture(scope='session')
52def service_env() -> dict[str, str]:
53 """
54 Override this to pass extra environment variables to the service.
55
56 @snippet samples/redis_service/testsuite/conftest.py service_env
57 @ingroup userver_testsuite_fixtures
58 """
59 return {}
60
61
62@pytest.fixture(scope='session')
63async def service_http_ping_url(service_config, service_baseurl) -> str | None:
64 """
65 Returns the service HTTP ping URL that is used by the testsuite to detect
66 that the service is ready to work. Returns None if there's no such URL.
67
68 By default, attempts to find server::handlers::Ping component by
69 "handler-ping" name in static config. Override this fixture to change the
70 behavior.
71
72 @ingroup userver_testsuite_fixtures
73 """
74 components = service_config['components_manager']['components']
75 ping_handler = components.get('handler-ping')
76 if ping_handler:
77 return url_util.join(service_baseurl, ping_handler['path'])
78 return None
79
80
81@pytest.fixture(scope='session')
82def service_non_http_health_checks( # pylint: disable=invalid-name
83 service_config,
84) -> net.HealthChecks:
85 """
86 Returns a health checks info.
87
88 By default, returns pytest_userver.utils.net.get_health_checks_info().
89
90 Override this fixture to change the way testsuite detects the tested
91 service being alive.
92
93 @ingroup userver_testsuite_fixtures
94 """
95
96 return net.get_health_checks_info(service_config)
97
98
99@pytest.fixture(scope='session')
101 """
102 Returns service start timeout in seconds.
103
104 Override this fixture to change the service start timeout.
105
106 @ingroup userver_testsuite_fixtures
107 """
108
109 return 100.0
110
111
112@pytest.fixture(scope='session')
114 """
115 If non-empty, defines a list of arguments starting with executable
116 that is used instead of `service_binary`. The final argument list consists of
117 `service_binary_launcher` plus the common service cmdline.
118
119 Can be used for:
120 - resource limiting (rlimit, cgroup)
121 - security limiting (capabilities, LSM)
122 - resource profiling (perf)
123 - tracing (strace, ltrace)
124
125 @see @ref pytest_userver.plugins.service.service_daemon_instance "service_daemon_instance"
126 @ingroup userver_testsuite_fixtures
127 """
128 return []
129
130
131@pytest.fixture(scope='session')
133 service_http_ping_url,
134 service_non_http_health_checks,
135) -> Callable[..., Awaitable[bool]] | None:
136 """
137 Returns the health check function used by
138 @ref pytest_userver.plugins.service.service_daemon_scope "service_daemon_scope"
139 to detect that the service has started and is ready to accept requests.
140
141 Returns None when service_http_ping_url is set, in which case
142 create_daemon_scope uses the ping URL directly. Otherwise returns a checker
143 based on the service_non_http_health_checks info.
144
145 Override this fixture to change the way the service readiness is detected.
146
147 @ingroup userver_testsuite_fixtures
148 """
149 assert service_http_ping_url or service_non_http_health_checks.tcp, (
150 '"service_http_ping_url" and "create_health_checker" fixtures '
151 'returned None. Testsuite is unable to detect if the service is ready '
152 'to accept requests.',
153 )
154
155 logger.debug(
156 'userver fixture "service_health_check" would check for "%s"',
157 service_non_http_health_checks,
158 )
159
160 if service_http_ping_url:
161 return None
162
163 class LocalCounters:
164 last_log_time = 0.0
165 attempts = 0
166
167 async def _checker(*, session, process) -> bool:
168 LocalCounters.attempts += 1
169 new_log_time = time.monotonic()
170 if new_log_time - LocalCounters.last_log_time > 1.0:
171 LocalCounters.last_log_time = new_log_time
172 logger.debug(
173 'userver fixture "service_health_check" checking "%s", attempt %s',
174 service_non_http_health_checks,
175 LocalCounters.attempts,
176 )
177
178 return await net.check_availability(service_non_http_health_checks)
179
180 return _checker
181
182
183@pytest.fixture(scope='session')
185 create_daemon_scope,
186 daemon_scoped_mark,
187 service_env,
188 service_http_ping_url,
189 service_config_path_temp,
190 service_binary,
191 service_binary_launcher,
192 service_health_check,
193 service_start_timeout,
194):
195 """
196 Prepares the start of the service daemon.
197 Configures the health checking via the service_health_check fixture.
198
199 @see @ref pytest_userver.plugins.service.service_daemon_instance "service_daemon_instance"
200 @ingroup userver_testsuite_fixtures
201 """
202 # In yandex-taxi-testsuite, each poll retry duration is 0.05 seconds.
203 poll_retries = int(service_start_timeout / 0.05)
204
205 async with create_daemon_scope(
206 args=service_binary_launcher + [str(service_binary), '--config', str(service_config_path_temp)],
207 ping_url=service_http_ping_url,
208 health_check=service_health_check,
209 env=service_env,
210 poll_retries=poll_retries,
211 ) as scope:
212 yield scope
213
214
215@pytest.fixture
216def extra_client_deps() -> None:
217 """
218 Service client dependencies hook. Feel free to override, e.g.:
219
220 @code
221 @pytest.fixture
222 def extra_client_deps(some_fixtures_to_wait_before_service_start):
223 pass
224 @endcode
225
226 @ingroup userver_testsuite_fixtures
227 """
228
229
230@pytest.fixture
231def auto_client_deps(request) -> None:
232 """
233 Ensures that the following fixtures, if available, are run before service start:
234
235 * `pgsql`
236 * `mongodb`
237 * `clickhouse`
238 * `rabbitmq`
239 * kafka (`kafka_producer`, `kafka_consumer`)
240 * `redis_store`
241 * `mysql`
242 * @ref pytest_userver.plugins.ydb.ydbsupport.ydb "ydb"
243 * @ref pytest_userver.plugins.scylla.scylla "scylla"
244 * @ref pytest_userver.plugins.grpc.mockserver.grpc_mockserver "grpc_mockserver"
245
246 To add other dependencies prefer overriding the
247 @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps"
248 fixture.
249
250 @ingroup userver_testsuite_fixtures
251 """
252 known_deps = {
253 'pgsql',
254 'mongodb',
255 'clickhouse',
256 'rabbitmq',
257 'kafka_producer',
258 'kafka_consumer',
259 'redis_store',
260 'mysql',
261 'ydb',
262 'scylla',
263 'grpc_mockserver',
264 }
265
266 try:
267 fixture_lookup_error = pytest.FixtureLookupError
268 except AttributeError:
269 # support for an older version of the pytest
270 import _pytest.fixtures
271
272 fixture_lookup_error = _pytest.fixtures.FixtureLookupError
273
274 resolved_deps = []
275 for dep in known_deps:
276 try:
277 request.getfixturevalue(dep)
278 resolved_deps.append(dep)
279 except fixture_lookup_error:
280 pass
281
282 logger.debug(
283 'userver fixture "auto_client_deps" resolved dependencies %s',
284 resolved_deps,
285 )
286
287
288@pytest.fixture
290 testpoint,
291 cleanup_userver_dumps,
292 userver_log_capture,
293 dynamic_config,
294 mock_configs_service,
295):
296 """
297 Service client dependencies hook, like
298 @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps".
299
300 Feel free to override globally in a more specific pytest plugin
301 (one that comes after userver plugins),
302 but make sure to depend on the original fixture:
303
304 @code
305 @pytest.fixture(name='builtin_client_deps')
306 def _builtin_client_deps(builtin_client_deps, some_extra_fixtures):
307 pass
308 @endcode
309
310 @ingroup userver_testsuite_fixtures
311 """
312
313
314@pytest.fixture
316 ensure_daemon_started,
317 service_daemon_scope,
318 builtin_client_deps,
319 auto_client_deps,
320 # User defined client deps must be last in order to use
321 # fixtures defined above.
322 extra_client_deps,
323):
324 """
325 Calls `ensure_daemon_started` on
326 @ref pytest_userver.plugins.service.service_daemon_scope "service_daemon_scope"
327 to actually start the service. Makes sure that all the dependencies are prepared
328 before the service starts.
329
330 @see @ref pytest_userver.plugins.service.extra_client_deps "extra_client_deps"
331 @see @ref pytest_userver.plugins.service.auto_client_deps "auto_client_deps"
332 @see @ref pytest_userver.plugins.service.builtin_client_deps "builtin_client_deps"
333 @ingroup userver_testsuite_fixtures
334 """
335 # TODO also run userver_client_cleanup here
336 return await ensure_daemon_started(service_daemon_scope)
337
338
339@pytest.fixture(scope='session')
340def daemon_scoped_mark(request) -> dict[str, Any] | None:
341 """
342 Depend on this fixture directly or transitively to make your fixture a per-daemon fixture.
343
344 Example:
345
346 @code
347 @pytest.fixture(scope='session')
348 def users_cache_state(daemon_scoped_mark, ...):
349 return UsersCacheState(users_list=[])
350 @endcode
351
352 For tests marked with `@pytest.mark.uservice_oneshot(...)`, the service will be restarted,
353 and all the per-daemon fixtures will be recreated.
354
355 This fixture returns kwargs passed to the `uservice_oneshot` mark (which may be an empty dict).
356 For normal tests, this fixture returns `None`.
357
358 @ingroup userver_testsuite_fixtures
359 """
360 # === How daemon-scoped fixtures work ===
361 # pytest always keeps no more than 1 instance of each parametrized fixture.
362 # When a parametrized fixture is requested, FixtureDef checks using __eq__ whether the current value
363 # of fixture param equals the cached value. If they differ, then the fixture and all the dependent fixtures
364 # are torn down (in reverse-dependency order), then the fixture is set up again.
365 # TLDR: when the param changes, the whole tree of daemon-specific fixtures is torn down.
366 return getattr(request, 'param', None)
367
368
369# @cond
370
371
372def pytest_configure(config):
373 config.addinivalue_line(
374 'markers',
375 'uservice_oneshot: use a per-test service daemon instance',
376 )
377
378
379def _contains_oneshot_marker(parametrize: Iterable[pytest.Mark]) -> bool:
380 """
381 Check if at least one of 'parametrize' marks is of the form:
382
383 @pytest.mark.parametrize(
384 "foo, bar",
385 [
386 ("a", 10),
387 pytest.param("b", 20, marks=pytest.mark.uservice_oneshot), # <====
388 ]
389 )
390 """
391 return any(
392 True
393 for parametrize_mark in parametrize
394 if len(parametrize_mark.args) >= 2
395 for parameter_set in parametrize_mark.args[1]
396 if hasattr(parameter_set, 'marks')
397 for mark in parameter_set.marks
398 if mark.name == 'uservice_oneshot'
399 )
400
401
402def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
403 oneshot_marker = metafunc.definition.get_closest_marker('uservice_oneshot')
404 parametrize_markers = metafunc.definition.iter_markers('parametrize')
405 if oneshot_marker is not None or _contains_oneshot_marker(parametrize_markers):
406 # Set a dummy parameter value. Actual param is patched in pytest_collection_modifyitems.
407 metafunc.parametrize(
408 (daemon_scoped_mark.__name__,),
409 [(None,)],
410 indirect=True,
411 # TODO use pytest.HIDDEN_PARAM after it becomes available
412 # https://github.com/pytest-dev/pytest/issues/13228
413 ids=['uservice_oneshot'],
414 scope='function',
415 )
416
417
418# TODO use dependent parametrize instead of patching param value after it becomes available
419# https://github.com/pytest-dev/pytest/issues/13233
420def pytest_collection_modifyitems(items: list[pytest.Item]):
421 for item in items:
422 oneshot_marker = item.get_closest_marker('uservice_oneshot')
423 if oneshot_marker and isinstance(item, pytest.Function):
424 func_item = typing.cast(pytest.Function, item)
425 func_item.callspec.params[daemon_scoped_mark.__name__] = dict(oneshot_marker.kwargs, function=func_item)
426
427
428# @endcond