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',
49ServiceConfigPatch: TypeAlias = Callable[[dict, dict],
None]
55logger = logging.getLogger(__name__)
58class _UserverConfigPlugin:
60 self._config_hooks = []
63 def userver_config_hooks(self):
64 return self._config_hooks
66 def pytest_plugin_registered(self, plugin, manager):
67 if not isinstance(plugin, types.ModuleType):
69 uhooks = getattr(plugin,
'USERVER_CONFIG_HOOKS',
None)
70 if uhooks
is not None:
71 self._config_hooks.extend(uhooks)
74@dataclasses.dataclass(frozen=True)
80def pytest_configure(config):
81 config.pluginmanager.register(_UserverConfigPlugin(),
'userver_config')
84def pytest_addoption(parser) -> None:
85 group = parser.getgroup(
'userver-config')
87 '--service-log-level',
89 choices=[
'trace',
'debug',
'info',
'warning',
'error',
'critical'],
94 help=
'Path to service.yaml file.',
97 '--service-config-vars',
99 help=
'Path to config_vars.yaml file.',
104 help=
'Path to secure_data.json file.',
109 help=
'Path to dynamic config fallback file.',
114 help=
'Dump config from binary before running tests',
121@pytest.fixture(scope='session')
124 Returns the path to service.yaml file set by command line
125 `--service-config` option.
127 Override this fixture to change the way path to the static config is provided.
129 @ingroup userver_testsuite_fixtures
131 if pytestconfig.option.dump_config:
135 pytestconfig.option.service_config,
137 return pytestconfig.option.service_config
140@pytest.fixture(scope='session')
143 Runs the service binary with `--dump-db-schema` argument, dumps the 0_db_schema.sql file with database schema and
146 Override this fixture to change the way to dump the database schema.
148 @ingroup userver_testsuite_fixtures
151 path = service_tmpdir.joinpath(
'schemas')
156 path /
'0_db_schema.sql',
161@pytest.fixture(scope='session')
164 Returns the path to config_vars.yaml file set by command line
165 `--service-config-vars` option.
167 Override this fixture to change the way path to config_vars.yaml is
170 @ingroup userver_testsuite_fixtures
172 return pytestconfig.option.service_config_vars
175@pytest.fixture(scope='session')
178 Returns the path to secure_data.json file set by command line
179 `--service-secdist` option.
181 Override this fixture to change the way path to secure_data.json is
184 @ingroup userver_testsuite_fixtures
186 return pytestconfig.option.service_secdist
189@pytest.fixture(scope='session')
192 Returns the path to dynamic config fallback file set by command line
193 `--config-fallback` option.
195 Override this fixture to change the way path to dynamic config fallback is
198 @ingroup userver_testsuite_fixtures
200 return pytestconfig.option.config_fallback
203@pytest.fixture(scope='session')
206 Returns the path for temporary files. The path is the same for the whole
207 session and files are not removed (at least by this fixture) between
210 @ingroup userver_testsuite_fixtures
212 return tmp_path_factory.mktemp(
213 pathlib.Path(service_binary).name,
218@pytest.fixture(scope='session')
226 Dumps the contents of the service_config_yaml and service_config_vars into a static config for
227 testsuite and returns the path to the config file.
229 @ingroup userver_testsuite_fixtures
231 dst_path = service_tmpdir /
'config.yaml'
233 service_config_yaml = dict(service_config_yaml)
234 if not service_config_vars:
235 service_config_yaml.pop(
'config_vars',
None)
237 config_vars_path = service_tmpdir /
'config_vars.yaml'
238 config_vars_text = yaml.dump(service_config_vars)
240 'userver fixture "service_config_path_temp" writes the patched static config vars to "%s":\n%s',
244 config_vars_path.write_text(config_vars_text)
245 service_config_yaml[
'config_vars'] = str(config_vars_path)
248 'userver fixture "service_config_path_temp" writes the patched static config to "%s" equivalent to:\n%s',
250 yaml.dump(service_config),
252 dst_path.write_text(yaml.dump(service_config_yaml))
257@pytest.fixture(scope='session')
260 Returns the static config values after the USERVER_CONFIG_HOOKS were
261 applied (if any). Prefer using
262 pytest_userver.plugins.config.service_config
264 @ingroup userver_testsuite_fixtures
266 return _service_config_hooked.config_yaml
269@pytest.fixture(scope='session')
272 Returns the static config variables (config_vars.yaml) values after the
273 USERVER_CONFIG_HOOKS were applied (if any). Prefer using
274 pytest_userver.plugins.config.service_config
276 @ingroup userver_testsuite_fixtures
278 return _service_config_hooked.config_vars
281def _substitute_values(config, service_config_vars: dict, service_env) ->
None:
282 if isinstance(config, dict):
283 for key, value
in config.items():
284 if not isinstance(value, str):
285 _substitute_values(value, service_config_vars, service_env)
288 if not value.startswith(
'$'):
291 new_value = service_config_vars.get(value[1:])
292 if new_value
is not None:
293 config[key] = new_value
296 env = config.get(f
'{key}#env')
299 new_value = service_env.get(env)
301 new_value = os.environ.get(env)
303 config[key] = new_value
306 fallback = config.get(f
'{key}#fallback')
308 config[key] = fallback
313 if isinstance(config, list):
314 for i, value
in enumerate(config):
315 if not isinstance(value, str):
316 _substitute_values(value, service_config_vars, service_env)
319 if not value.startswith(
'$'):
322 new_value = service_config_vars.get(value[1:])
323 if new_value
is not None:
324 config[i] = new_value
327@pytest.fixture(scope='session')
330 A function that takes `config_yaml`, `config_vars` and applies all
331 substitutions just like the service would.
333 Useful when patching the service config. It's a good idea to pass
334 a component's config instead of the whole `config_yaml` to avoid
337 @warning The returned YAML is a clone, mutating it will not modify
338 the actual config while in a config hook!
340 @ingroup userver_testsuite_fixtures
343 def substitute(config_yaml, config_vars, /):
344 if config_yaml
is not None and not isinstance(config_yaml, dict)
and not isinstance(config_yaml, list):
346 f
'{substitute_config_vars.__name__} can only be meaningfully '
347 'called with dict and list nodes of config_yaml, while given: '
348 f
'{config_yaml!r}. Pass a containing object instead.',
351 config = copy.deepcopy(config_yaml)
352 _substitute_values(config, config_vars, service_env)
358@pytest.fixture(scope='session')
362 substitute_config_vars,
365 Returns the static config values after the USERVER_CONFIG_HOOKS were
366 applied (if any) and with all the '$', environment and fallback variables
369 @ingroup userver_testsuite_fixtures
372 config.pop(
'config_vars',
None)
376@pytest.fixture(scope='session')
377def _original_service_config(
379 service_config_vars_path,
384 assert service_config_path
is not None,
'Please specify proper path to the static config file, not None'
386 with open(service_config_path, mode=
'rt')
as fp:
387 config_yaml = yaml.safe_load(fp)
389 if service_config_vars_path:
390 with open(service_config_vars_path, mode=
'rt')
as fp:
391 config_vars = yaml.safe_load(fp)
395 return _UserverConfig(config_yaml=config_yaml, config_vars=config_vars)
398@pytest.fixture(scope='session')
399def _service_config_hooked(
403 _original_service_config,
405 config_yaml = copy.deepcopy(_original_service_config.config_yaml)
406 config_vars = copy.deepcopy(_original_service_config.config_vars)
408 plugin = pytestconfig.pluginmanager.get_plugin(
'userver_config')
409 local_hooks = (daemon_scoped_mark
or {}).get(
'config_hooks', ())
411 for hook
in itertools.chain(plugin.userver_config_hooks, local_hooks):
412 if not callable(hook):
413 hook_func = request.getfixturevalue(hook)
416 hook_func(config_yaml, config_vars)
418 return _UserverConfig(config_yaml=config_yaml, config_vars=config_vars)
421@pytest.fixture(scope='session')
422def _service_config_substitution_vars(request, mockserver_info) -> Mapping[str, str]:
423 substitution_vars = {
424 'mockserver': mockserver_info.base_url.removesuffix(
'/'),
426 if request.config.pluginmanager.hasplugin(
'pytest_userver.plugins.grpc.mockserver'):
427 grpc_mockserver_endpoint = request.getfixturevalue(
'grpc_mockserver_endpoint')
428 substitution_vars[
'grpc_mockserver'] = grpc_mockserver_endpoint
429 return substitution_vars
432@pytest.fixture(scope='session')
435 Replaces substitution vars in all strings within `config_vars` using
436 [string.Template.substitute](https://docs.python.org/3/library/string.html#string.Template.substitute).
438 Substitution vars can be used as a shorthand for writing a full-fledged @ref SERVICE_CONFIG_HOOKS "config hook"
439 in many common cases.
441 Unlike normal `config_vars`, substitution vars can also apply to a part of a string.
442 For example, for `config_vars` entry
445 frobnicator-url: $mockserver/frobnicator
448 a possible patching result is as follows:
451 frobnicator-url: http://127.0.0.1:1234/frobnicator
454 Currently, the following substitution vars are supported:
456 * `mockserver` - mockserver url
457 * `grpc_mockserver` - grpc mockserver endpoint
459 @ingroup userver_testsuite_fixtures
462 def _substitute(key, value, parent: list | dict) ->
None:
463 if isinstance(value, str):
464 parent[key] = string.Template(value).safe_substitute(_service_config_substitution_vars)
465 elif isinstance(value, dict):
466 for child_key, child_value
in value.items():
467 _substitute(child_key, child_value, value)
468 elif isinstance(value, list):
469 for child_key, child_value
in enumerate(value):
470 _substitute(child_key, child_value, value)
472 def patch_config(config_yaml, config_vars):
473 for key, value
in config_vars.items():
474 _substitute(key, value, config_vars)
479@pytest.fixture(scope='session')
482 Returns a function that adjusts the static configuration file for testsuite.
483 Sets the `server.listener.port` to listen on
484 @ref pytest_userver.plugins.base.service_port "service_port" fixture value;
485 sets the `server.listener-monitor.port` to listen on
486 @ref pytest_userver.plugins.base.monitor_port "monitor_port"
489 @ingroup userver_testsuite_fixtures
492 def _patch_config(config_yaml, config_vars):
493 components = config_yaml[
'components_manager'][
'components']
494 if 'server' in components:
495 server = components[
'server']
496 if 'listener' in server:
497 server[
'listener'][
'port'] = service_port
499 if 'listener-monitor' in server:
500 server[
'listener-monitor'][
'port'] = monitor_port
505@pytest.fixture(scope='session')
508 By default, userver HTTP client is only allowed to talk to mockserver
509 when running in testsuite. This makes tests repeatable and encapsulated.
511 Override this fixture to whitelist some additional URLs.
512 It is still strongly advised to only talk to localhost in tests.
514 @ingroup userver_testsuite_fixtures
519@pytest.fixture(scope='session')
523 allowed_url_prefixes_extra,
524) -> ServiceConfigPatch:
526 Returns a function that adjusts the static configuration file for testsuite.
527 Sets increased timeout and limits allowed URLs for `http-client-core` component.
529 @ingroup userver_testsuite_fixtures
532 def patch_config(config, config_vars) -> None:
533 components: dict = config[
'components_manager'][
'components']
534 if not {
'http-client-core',
'testsuite-support'}.issubset(
538 if components[
'http-client-core']
is None:
539 components[
'http-client-core'] = {}
540 http_client_core = components[
'http-client-core']
541 http_client_core[
'testsuite-enabled'] =
True
542 http_client_core[
'testsuite-timeout'] =
'10s'
544 allowed_urls = [mockserver_info.base_url]
545 if mockserver_ssl_info:
546 allowed_urls.append(mockserver_ssl_info.base_url)
547 allowed_urls += allowed_url_prefixes_extra
548 http_client_core[
'testsuite-allowed-url-prefixes'] = allowed_urls
553@pytest.fixture(scope='session')
556 Default log level to use in userver if no command line option was provided.
560 @ingroup userver_testsuite_fixtures
565@pytest.fixture(scope='session')
568 Returns --service-log-level value if provided, otherwise returns
569 userver_default_log_level() value from fixture.
571 @ingroup userver_testsuite_fixtures
573 if pytestconfig.option.service_log_level:
574 return pytestconfig.option.service_log_level
575 return userver_default_log_level
578@pytest.fixture(scope='session')
581 Returns a function that adjusts the static configuration file for testsuite.
582 Sets the `logging.loggers.default` to log to `@stderr` with level set
583 from `--service-log-level` pytest configuration option.
585 @ingroup userver_testsuite_fixtures
588 if _service_logfile_path:
589 default_file_path = str(_service_logfile_path)
591 default_file_path =
'@stderr'
593 def _patch_config(config_yaml, config_vars):
594 components = config_yaml[
'components_manager'][
'components']
595 if 'logging' in components:
596 loggers = components[
'logging'].setdefault(
'loggers', {})
597 for logger
in loggers.values():
598 logger[
'file_path'] =
'@null'
599 loggers[
'default'] = {
600 'file_path': default_file_path,
601 'level': userver_log_level,
602 'overflow_behavior':
'discard',
604 config_vars[
'logger_level'] = userver_log_level
609@pytest.fixture(scope='session')
612 Returns a function that adjusts the static configuration file for testsuite.
613 Sets the `otlp-logger.load-enabled` to `false` to disable OTLP logging and
614 leave the default file logger.
616 @ingroup userver_testsuite_fixtures
619 def _patch_config(config_yaml, config_vars):
620 components = config_yaml[
'components_manager'][
'components']
621 if 'otlp-logger' in components:
622 components[
'otlp-logger'][
'load-enabled'] =
False
627@pytest.fixture(scope='session')
630 Returns a function that adjusts the static configuration file for testsuite.
632 Sets up `testsuite-support` component, which:
634 - increases timeouts for userver drivers
635 - disables periodic cache updates
636 - enables testsuite tasks
638 Sets the `testsuite-enabled` in config_vars.yaml to `True`; sets the
639 `tests-control.testpoint-url` to mockserver URL.
641 @ingroup userver_testsuite_fixtures
644 def _set_postgresql_options(testsuite_support: dict) ->
None:
645 testsuite_support[
'testsuite-pg-execute-timeout'] =
'35s'
646 testsuite_support[
'testsuite-pg-statement-timeout'] =
'30s'
647 testsuite_support[
'testsuite-pg-readonly-master-expected'] =
True
649 def _set_redis_timeout(testsuite_support: dict) ->
None:
650 testsuite_support[
'testsuite-redis-timeout-connect'] =
'40s'
651 testsuite_support[
'testsuite-redis-timeout-single'] =
'30s'
652 testsuite_support[
'testsuite-redis-timeout-all'] =
'30s'
654 def _disable_cache_periodic_update(testsuite_support: dict) ->
None:
655 testsuite_support[
'testsuite-periodic-update-enabled'] =
False
657 def patch_config(config, config_vars) -> None:
659 config[
'components_manager'].pop(
'graceful_shutdown_interval',
None)
660 components: dict = config[
'components_manager'][
'components']
661 if 'testsuite-support' not in components:
663 if components[
'testsuite-support']
is None:
664 components[
'testsuite-support'] = {}
665 testsuite_support = components[
'testsuite-support']
666 testsuite_support[
'testsuite-increased-timeout'] =
'30s'
667 testsuite_support[
'testsuite-grpc-is-tls-enabled'] =
False
668 _set_postgresql_options(testsuite_support)
669 _set_redis_timeout(testsuite_support)
670 service_runner = pytestconfig.option.service_runner_mode
671 if not service_runner:
672 _disable_cache_periodic_update(testsuite_support)
673 testsuite_support[
'testsuite-tasks-enabled'] =
not service_runner
674 testsuite_support[
'testsuite-periodic-dumps-enabled'] =
'$userver-dumps-periodic'
675 components[
'testsuite-support'] = testsuite_support
677 config_vars[
'testsuite-enabled'] =
True
678 if 'tests-control' in components:
679 components[
'tests-control'][
'testpoint-url'] = mockserver_info.url(
686@pytest.fixture(scope='session')
689 Returns a function that adjusts the static configuration file for testsuite.
690 Sets the `default-secdist-provider.config` to the value of
691 @ref pytest_userver.plugins.config.service_secdist_path "service_secdist_path"
694 @ingroup userver_testsuite_fixtures
697 def _patch_config(config_yaml, config_vars):
698 if not service_secdist_path:
701 components = config_yaml[
'components_manager'][
'components']
702 if 'default-secdist-provider' not in components:
705 if not service_secdist_path.is_file():
707 f
'"{service_secdist_path}" is not a file. Provide a '
708 f
'"--service-secdist" pytest option or override the '
709 f
'"service_secdist_path" fixture.',
711 components[
'default-secdist-provider'][
'config'] = str(
712 service_secdist_path,
718@pytest.fixture(scope='session')
719def userver_config_testsuite_middleware(userver_testsuite_middleware_enabled: bool) -> ServiceConfigPatch:
720 def patch_config(config_yaml, config_vars):
721 if not userver_testsuite_middleware_enabled:
724 components = config_yaml[
'components_manager'][
'components']
725 if 'server' not in components:
728 pipeline_builder = components.setdefault(
729 'default-server-middleware-pipeline-builder',
732 middlewares = pipeline_builder.setdefault(
'append', [])
733 middlewares.append(
'testsuite-exceptions-handling-middleware')
738@pytest.fixture(scope='session')
740 """Whether testsuite middleware is enabled."""