2Work with the configuration files of the service in testsuite.
16from collections.abc
import Callable
17from collections.abc
import Mapping
18from typing
import TypeAlias
38USERVER_CONFIG_HOOKS = [
39 'userver_config_substitutions',
40 'userver_config_http_server',
41 'userver_config_http_client',
42 'userver_config_logging',
43 'userver_config_logging_otlp',
44 'userver_config_testsuite',
45 'userver_config_secdist',
46 'userver_config_testsuite_middleware',
47 'userver_config_deadlock_detector',
50ServiceConfigPatch: TypeAlias = Callable[[dict, dict],
None]
56logger = logging.getLogger(__name__)
59class _UserverConfigPlugin:
61 self._config_hooks = []
64 def userver_config_hooks(self):
65 return self._config_hooks
67 def pytest_plugin_registered(self, plugin, manager):
68 if not isinstance(plugin, types.ModuleType):
70 uhooks = getattr(plugin,
'USERVER_CONFIG_HOOKS',
None)
71 if uhooks
is not None:
72 self._config_hooks.extend(uhooks)
75@dataclasses.dataclass(frozen=True)
81def pytest_configure(config):
82 config.pluginmanager.register(_UserverConfigPlugin(),
'userver_config')
85def pytest_addoption(parser) -> None:
86 group = parser.getgroup(
'userver-config')
88 '--service-log-level',
90 choices=[
'trace',
'debug',
'info',
'warning',
'error',
'critical'],
95 help=
'Path to service.yaml file.',
98 '--service-config-vars',
100 help=
'Path to config_vars.yaml file.',
105 help=
'Path to secure_data.json file.',
110 help=
'Path to dynamic config fallback file.',
115 help=
'Dump config from binary before running tests',
122@pytest.fixture(scope='session')
125 Returns the path to service.yaml file set by command line
126 `--service-config` option.
128 Override this fixture to change the way path to the static config is provided.
130 @ingroup userver_testsuite_fixtures
132 if pytestconfig.option.dump_config:
136 pytestconfig.option.service_config,
138 return pytestconfig.option.service_config
141@pytest.fixture(scope='session')
144 Runs the service binary with `--dump-db-schema` argument, dumps the 0_db_schema.sql file with database schema and
147 Override this fixture to change the way to dump the database schema.
149 @ingroup userver_testsuite_fixtures
152 path = service_tmpdir.joinpath(
'schemas')
157 path /
'0_db_schema.sql',
162@pytest.fixture(scope='session')
165 Returns the path to config_vars.yaml file set by command line
166 `--service-config-vars` option.
168 Override this fixture to change the way path to config_vars.yaml is
171 @ingroup userver_testsuite_fixtures
173 return pytestconfig.option.service_config_vars
176@pytest.fixture(scope='session')
179 Returns the path to secure_data.json file set by command line
180 `--service-secdist` option.
182 Override this fixture to change the way path to secure_data.json is
185 @ingroup userver_testsuite_fixtures
187 return pytestconfig.option.service_secdist
190@pytest.fixture(scope='session')
193 Returns the path to dynamic config fallback file set by command line
194 `--config-fallback` option.
196 Override this fixture to change the way path to dynamic config fallback is
199 @ingroup userver_testsuite_fixtures
201 return pytestconfig.option.config_fallback
204@pytest.fixture(scope='session')
207 Returns the path for temporary files. The path is the same for the whole
208 session and files are not removed (at least by this fixture) between
211 @ingroup userver_testsuite_fixtures
213 return tmp_path_factory.mktemp(
214 pathlib.Path(service_binary).name,
219@pytest.fixture(scope='session')
227 Dumps the contents of the service_config_yaml and service_config_vars into a static config for
228 testsuite and returns the path to the config file.
230 @ingroup userver_testsuite_fixtures
232 dst_path = service_tmpdir /
'config.yaml'
234 service_config_yaml = dict(service_config_yaml)
235 if not service_config_vars:
236 service_config_yaml.pop(
'config_vars',
None)
238 config_vars_path = service_tmpdir /
'config_vars.yaml'
239 config_vars_text = yaml.dump(service_config_vars)
241 'userver fixture "service_config_path_temp" writes the patched static config vars to "%s":\n%s',
245 config_vars_path.write_text(config_vars_text)
246 service_config_yaml[
'config_vars'] = str(config_vars_path)
249 'userver fixture "service_config_path_temp" writes the patched static config to "%s" equivalent to:\n%s',
251 yaml.dump(service_config),
253 dst_path.write_text(yaml.dump(service_config_yaml))
258@pytest.fixture(scope='session')
261 Returns the static config values after the USERVER_CONFIG_HOOKS were
262 applied (if any). Prefer using
263 pytest_userver.plugins.config.service_config
265 @ingroup userver_testsuite_fixtures
267 return _service_config_hooked.config_yaml
270@pytest.fixture(scope='session')
273 Returns the static config variables (config_vars.yaml) values after the
274 USERVER_CONFIG_HOOKS were applied (if any). Prefer using
275 pytest_userver.plugins.config.service_config
277 @ingroup userver_testsuite_fixtures
279 return _service_config_hooked.config_vars
282def _substitute_values(config, service_config_vars: dict, service_env) ->
None:
283 if isinstance(config, dict):
284 for key, value
in config.items():
285 if not isinstance(value, str):
286 _substitute_values(value, service_config_vars, service_env)
289 if not value.startswith(
'$'):
292 new_value = service_config_vars.get(value[1:])
293 if new_value
is not None:
294 config[key] = new_value
297 env = config.get(f
'{key}#env')
300 new_value = service_env.get(env)
302 new_value = os.environ.get(env)
304 config[key] = new_value
307 fallback = config.get(f
'{key}#fallback')
309 config[key] = fallback
314 if isinstance(config, list):
315 for i, value
in enumerate(config):
316 if not isinstance(value, str):
317 _substitute_values(value, service_config_vars, service_env)
320 if not value.startswith(
'$'):
323 new_value = service_config_vars.get(value[1:])
324 if new_value
is not None:
325 config[i] = new_value
328@pytest.fixture(scope='session')
331 A function that takes `config_yaml`, `config_vars` and applies all
332 substitutions just like the service would.
334 Useful when patching the service config. It's a good idea to pass
335 a component's config instead of the whole `config_yaml` to avoid
338 @warning The returned YAML is a clone, mutating it will not modify
339 the actual config while in a config hook!
341 @ingroup userver_testsuite_fixtures
344 def substitute(config_yaml, config_vars, /):
345 if config_yaml
is not None and not isinstance(config_yaml, dict)
and not isinstance(config_yaml, list):
347 f
'{substitute_config_vars.__name__} can only be meaningfully '
348 'called with dict and list nodes of config_yaml, while given: '
349 f
'{config_yaml!r}. Pass a containing object instead.',
352 config = copy.deepcopy(config_yaml)
353 _substitute_values(config, config_vars, service_env)
359@pytest.fixture(scope='session')
363 substitute_config_vars,
366 Returns the static config values after the USERVER_CONFIG_HOOKS were
367 applied (if any) and with all the '$', environment and fallback variables
370 @ingroup userver_testsuite_fixtures
373 config.pop(
'config_vars',
None)
377@pytest.fixture(scope='session')
378def _original_service_config(
380 service_config_vars_path,
385 assert service_config_path
is not None,
'Please specify proper path to the static config file, not None'
387 with open(service_config_path, mode=
'rt')
as fp:
388 config_yaml = yaml.safe_load(fp)
390 if service_config_vars_path:
391 with open(service_config_vars_path, mode=
'rt')
as fp:
392 config_vars = yaml.safe_load(fp)
396 return _UserverConfig(config_yaml=config_yaml, config_vars=config_vars)
399@pytest.fixture(scope='session')
400def _service_config_hooked(
404 _original_service_config,
406 config_yaml = copy.deepcopy(_original_service_config.config_yaml)
407 config_vars = copy.deepcopy(_original_service_config.config_vars)
409 plugin = pytestconfig.pluginmanager.get_plugin(
'userver_config')
410 local_hooks = (daemon_scoped_mark
or {}).get(
'config_hooks', ())
412 for hook
in itertools.chain(plugin.userver_config_hooks, local_hooks):
413 if not callable(hook):
414 hook_func = request.getfixturevalue(hook)
417 hook_func(config_yaml, config_vars)
419 return _UserverConfig(config_yaml=config_yaml, config_vars=config_vars)
422@pytest.fixture(scope='session')
423def _service_config_substitution_vars(request, mockserver_info) -> Mapping[str, str]:
424 substitution_vars = {
425 'mockserver': mockserver_info.base_url.removesuffix(
'/'),
427 if request.config.pluginmanager.hasplugin(
'pytest_userver.plugins.grpc.mockserver'):
428 grpc_mockserver_endpoint = request.getfixturevalue(
'grpc_mockserver_endpoint')
429 substitution_vars[
'grpc_mockserver'] = grpc_mockserver_endpoint
430 return substitution_vars
433@pytest.fixture(scope='session')
436 Replaces substitution vars in all strings within `config_vars` using
437 [string.Template.substitute](https://docs.python.org/3/library/string.html#string.Template.substitute).
439 Substitution vars can be used as a shorthand for writing a full-fledged @ref SERVICE_CONFIG_HOOKS "config hook"
440 in many common cases.
442 Unlike normal `config_vars`, substitution vars can also apply to a part of a string.
443 For example, for `config_vars` entry
446 frobnicator-url: $mockserver/frobnicator
449 a possible patching result is as follows:
452 frobnicator-url: http://127.0.0.1:1234/frobnicator
455 Currently, the following substitution vars are supported:
457 * `mockserver` - mockserver url
458 * `grpc_mockserver` - grpc mockserver endpoint
460 @ingroup userver_testsuite_fixtures
463 def _substitute(key, value, parent: list | dict) ->
None:
464 if isinstance(value, str):
465 parent[key] = string.Template(value).safe_substitute(_service_config_substitution_vars)
466 elif isinstance(value, dict):
467 for child_key, child_value
in value.items():
468 _substitute(child_key, child_value, value)
469 elif isinstance(value, list):
470 for child_key, child_value
in enumerate(value):
471 _substitute(child_key, child_value, value)
473 def patch_config(config_yaml, config_vars):
474 for key, value
in config_vars.items():
475 _substitute(key, value, config_vars)
480@pytest.fixture(scope='session')
483 Returns a function that adjusts the static configuration file for testsuite.
484 Sets the `server.listener.port` to listen on
485 @ref pytest_userver.plugins.base.service_port "service_port" fixture value;
486 sets the `server.listener-monitor.port` to listen on
487 @ref pytest_userver.plugins.base.monitor_port "monitor_port"
490 @ingroup userver_testsuite_fixtures
493 def _patch_config(config_yaml, config_vars):
494 components = config_yaml[
'components_manager'][
'components']
495 if server := components.get(
'server'):
496 for listener_name, new_port
in [(
'listener', service_port), (
'listener-monitor', monitor_port)]:
497 if listener := server.get(listener_name):
498 ports = listener.get(
'ports')
499 if ports
and 'port' in ports[0]:
500 ports[0][
'port'] = new_port
502 listener[
'port'] = new_port
507@pytest.fixture(scope='session')
510 By default, userver HTTP client is only allowed to talk to mockserver
511 when running in testsuite. This makes tests repeatable and encapsulated.
513 Override this fixture to whitelist some additional URLs.
514 It is still strongly advised to only talk to localhost in tests.
516 @ingroup userver_testsuite_fixtures
521@pytest.fixture(scope='session')
525 allowed_url_prefixes_extra,
528 Returns final list of allowed urls prefixes when running in testsuite.
530 For most of cases you DO NOT want to override this fixture. Use "allowed_url_prefixes_extra" instead.
532 @ingroup userver_testsuite_fixtures
534 allowed_urls: list[str] = [mockserver_info.base_url]
536 if mockserver_ssl_info:
537 allowed_urls.append(mockserver_ssl_info.base_url)
539 allowed_urls += allowed_url_prefixes_extra
542 allowed_urls.append(mockserver_info.ws_url(
'/'))
543 if mockserver_ssl_info:
544 allowed_urls.append(mockserver_ssl_info.ws_url(
'/'))
549@pytest.fixture(scope='session')
551 allowed_url_prefixes,
552) -> ServiceConfigPatch:
554 Returns a function that adjusts the static configuration file for testsuite.
555 Sets increased timeout and limits allowed URLs for `http-client-core` component.
557 @ingroup userver_testsuite_fixtures
560 def patch_config(config, config_vars) -> None:
561 components: dict = config[
'components_manager'][
'components']
562 if not {
'http-client-core',
'testsuite-support'}.issubset(
566 if components[
'http-client-core']
is None:
567 components[
'http-client-core'] = {}
568 http_client_core = components[
'http-client-core']
569 http_client_core[
'testsuite-enabled'] =
True
570 http_client_core[
'testsuite-timeout'] =
'10s'
572 http_client_core[
'testsuite-allowed-url-prefixes'] = allowed_url_prefixes
577@pytest.fixture(scope='session')
580 Default log level to use in userver if no command line option was provided.
584 @ingroup userver_testsuite_fixtures
589@pytest.fixture(scope='session')
592 Returns --service-log-level value if provided, otherwise returns
593 userver_default_log_level() value from fixture.
595 @ingroup userver_testsuite_fixtures
597 if pytestconfig.option.service_log_level:
598 return pytestconfig.option.service_log_level
599 return userver_default_log_level
602@pytest.fixture(scope='session')
605 Returns a function that adjusts the static configuration file for testsuite.
606 Sets the `logging.loggers.default` to log to `@stderr` with level set
607 from `--service-log-level` pytest configuration option.
609 @ingroup userver_testsuite_fixtures
612 if _service_logfile_path:
613 default_file_path = str(_service_logfile_path)
615 default_file_path =
'@stderr'
617 def _patch_config(config_yaml, config_vars):
618 components = config_yaml[
'components_manager'][
'components']
619 if 'logging' in components:
620 loggers = components[
'logging'].setdefault(
'loggers', {})
621 for logger
in loggers.values():
622 logger[
'file_path'] =
'@null'
623 loggers[
'default'] = {
624 'file_path': default_file_path,
625 'level': userver_log_level,
626 'overflow_behavior':
'discard',
628 config_vars[
'logger_level'] = userver_log_level
633@pytest.fixture(scope='session')
636 Returns a function that adjusts the static configuration file for testsuite.
637 Sets the `otlp-logger.load-enabled` to `false` to disable OTLP logging and
638 leave the default file logger.
640 @ingroup userver_testsuite_fixtures
643 def _patch_config(config_yaml, config_vars):
644 components = config_yaml[
'components_manager'][
'components']
645 if 'otlp-logger' in components:
646 components[
'otlp-logger'][
'load-enabled'] =
False
651@pytest.fixture(scope='session')
654 Returns a function that adjusts the static configuration file for testsuite.
656 Sets up `testsuite-support` component, which:
658 - increases timeouts for userver drivers
659 - disables periodic cache updates
660 - enables testsuite tasks
662 Sets the `testsuite-enabled` in config_vars.yaml to `True`; sets the
663 `tests-control.testpoint-url` to mockserver URL.
665 @ingroup userver_testsuite_fixtures
668 def _set_postgresql_options(testsuite_support: dict) ->
None:
669 testsuite_support[
'testsuite-pg-execute-timeout'] =
'35s'
670 testsuite_support[
'testsuite-pg-statement-timeout'] =
'30s'
671 testsuite_support[
'testsuite-pg-readonly-master-expected'] =
True
673 def _set_redis_timeout(testsuite_support: dict) ->
None:
674 testsuite_support[
'testsuite-redis-timeout-connect'] =
'40s'
675 testsuite_support[
'testsuite-redis-timeout-single'] =
'30s'
676 testsuite_support[
'testsuite-redis-timeout-all'] =
'30s'
678 def _disable_cache_periodic_update(testsuite_support: dict) ->
None:
679 testsuite_support[
'testsuite-periodic-update-enabled'] =
False
681 def patch_config(config, config_vars) -> None:
683 config[
'components_manager'].pop(
'graceful_shutdown_interval',
None)
684 config[
'components_manager'].pop(
'graceful_shutdown_continue_accepting_requests_interval',
None)
685 config[
'components_manager'].pop(
'graceful_shutdown_pending_requests_completion_interval',
None)
686 components: dict = config[
'components_manager'][
'components']
687 if 'testsuite-support' not in components:
689 if components[
'testsuite-support']
is None:
690 components[
'testsuite-support'] = {}
691 testsuite_support = components[
'testsuite-support']
692 testsuite_support[
'testsuite-increased-timeout'] =
'30s'
693 testsuite_support[
'testsuite-grpc-is-tls-enabled'] =
False
694 _set_postgresql_options(testsuite_support)
695 _set_redis_timeout(testsuite_support)
696 service_runner = pytestconfig.option.service_runner_mode
697 if not service_runner:
698 _disable_cache_periodic_update(testsuite_support)
699 testsuite_support[
'testsuite-tasks-enabled'] =
not service_runner
700 testsuite_support[
'testsuite-periodic-dumps-enabled'] =
'$userver-dumps-periodic'
701 components[
'testsuite-support'] = testsuite_support
703 config_vars[
'testsuite-enabled'] =
True
704 if 'tests-control' in components:
705 components[
'tests-control'][
'testpoint-url'] = mockserver_info.url(
712@pytest.fixture(scope='session')
715 Returns a function that adjusts the static configuration file for testsuite.
716 Sets the `default-secdist-provider.config` to the value of
717 @ref pytest_userver.plugins.config.service_secdist_path "service_secdist_path"
720 @ingroup userver_testsuite_fixtures
723 def _patch_config(config_yaml, config_vars):
724 if not service_secdist_path:
727 components = config_yaml[
'components_manager'][
'components']
728 if 'default-secdist-provider' not in components:
731 if not service_secdist_path.is_file():
733 f
'"{service_secdist_path}" is not a file. Provide a '
734 f
'"--service-secdist" pytest option or override the '
735 f
'"service_secdist_path" fixture.',
737 components[
'default-secdist-provider'][
'config'] = str(
738 service_secdist_path,
744@pytest.fixture(scope='session')
745def userver_config_testsuite_middleware(userver_testsuite_middleware_enabled: bool) -> ServiceConfigPatch:
746 def patch_config(config_yaml, config_vars):
747 if not userver_testsuite_middleware_enabled:
750 components = config_yaml[
'components_manager'][
'components']
751 if 'server' not in components:
754 pipeline_builder_name = components[
'server'].get(
755 'middleware-pipeline-builder',
'default-server-middleware-pipeline-builder'
757 pipeline_builder = components.setdefault(pipeline_builder_name, {})
758 middlewares = pipeline_builder.setdefault(
'append', [])
759 middlewares.append(
'testsuite-exceptions-handling-middleware')
764@pytest.fixture(scope='session')
766 """Whether testsuite middleware is enabled."""
770@pytest.fixture(scope='session')
773 Returns a function that adjusts the static configuration file for testsuite.
774 Sets the `deadlock_detector` parameter of the `coro_pool` component to the value of
775 @ref pytest_userver.plugins.config.userver_deadlock_detector_mode "userver_deadlock_detector_mode"
778 @ingroup userver_testsuite_fixtures
781 def patch_config(config_yaml, config_vars):
782 coro_pool = config_yaml[
'components_manager'].setdefault(
'coro_pool', {})
783 coro_pool[
'deadlock_detector'] = userver_deadlock_detector_mode
788@pytest.fixture(scope='session')
791 Returns Deadlock detector mode for testsuite.
792 Override this fixture to modify the deadlock detector settings.
793 By default, it operates in `detect-only` mode. For a full list of available options, refer to the
794 `coro_pool.deadlock_detector` parameter in the `components::ManagerControllerComponent`.
796 @ingroup userver_testsuite_fixtures