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