userver: /data/code/userver/testsuite/pytest_plugins/pytest_userver/plugins/dynamic_config.py Source File
Loading...
Searching...
No Matches
dynamic_config.py
1"""
2Supply dynamic configs for the service in testsuite.
3"""
4
5# pylint: disable=redefined-outer-name
6from collections.abc import Callable
7from collections.abc import Iterable
8import dataclasses
9import json
10import pathlib
11
12import pytest
13
14from pytest_userver import dynconf
15
16USERVER_CONFIG_HOOKS = [
17 'userver_config_dynconf_cache',
18 'userver_config_dynconf_fallback',
19 'userver_config_dynconf_url',
20]
21USERVER_CACHE_CONTROL_HOOKS = {
22 'dynamic-config-client-updater': '_userver_dynconfig_cache_control',
23}
24
25
26@pytest.fixture
28 request,
29 search_path,
30 object_substitute,
31 cache_invalidation_state,
32 _dynamic_config_defaults_storage,
33 config_service_defaults,
34 dynamic_config_changelog,
35 _dynconf_load_json_cached,
36 dynconf_cache_names,
37) -> dynconf.DynamicConfig:
38 """
39 Fixture that allows to control dynamic config values used by the service.
40
41 Example:
42
43 @snippet core/functional_tests/basic_chaos/tests-nonchaos/handlers/test_log_request_headers.py dynamic_config usage
44
45 Example with @ref kill_switches "kill switches":
46
47 @snippet core/functional_tests/dynamic_configs/tests/test_examples.py dynamic_config usage with kill switches
48
49 HTTP and gRPC client requests call `update_server_state` automatically before each request.
50
51 For main dynamic config documentation:
52
53 @see @ref dynamic_config_testsuite
54
55 See also other related fixtures:
56 * @ref pytest_userver.plugins.dynamic_config.dynamic_config "config_service_defaults"
57 * @ref pytest_userver.plugins.dynamic_config.dynamic_config "dynamic_config_fallback_patch"
58 * @ref pytest_userver.plugins.dynamic_config.dynamic_config "mock_configs_service"
59
60 @ingroup userver_testsuite_fixtures
61 """
62 config = dynconf.DynamicConfig(
63 initial_values=config_service_defaults,
64 defaults=_dynamic_config_defaults_storage.snapshot,
65 config_cache_components=dynconf_cache_names,
66 cache_invalidation_state=cache_invalidation_state,
67 changelog=dynamic_config_changelog,
68 )
69
70 with dynamic_config_changelog.rollback(config_service_defaults):
71 updates = {}
72 for path in reversed(list(search_path('config.json'))):
73 values = _dynconf_load_json_cached(path)
74 updates.update(values)
75 for marker in request.node.iter_markers('config'):
76 value_update_kwargs = {
77 key: value for key, value in marker.kwargs.items() if value is not dynconf.USE_STATIC_DEFAULT
78 }
79 value_updates_json = object_substitute(value_update_kwargs)
80 updates.update(value_updates_json)
81 config.set_values_unsafe(updates)
82
83 kill_switches_disabled = []
84 for marker in request.node.iter_markers('config'):
85 kill_switches_disabled.extend(
86 key for key, value in marker.kwargs.items() if value is dynconf.USE_STATIC_DEFAULT
87 )
88 config.switch_to_static_default(*kill_switches_disabled)
89
90 yield config
91
92
93# @cond
94
95
96def pytest_configure(config):
97 config.addinivalue_line(
98 'markers',
99 'config: per-test dynamic config values',
100 )
101 config.addinivalue_line(
102 'markers',
103 'disable_config_check: disable config mark keys check',
104 )
105
106
107# @endcond
108
109
110@pytest.fixture(scope='session')
111def dynconf_cache_names() -> Iterable[str]:
112 return tuple(USERVER_CACHE_CONTROL_HOOKS.keys())
113
114
115@pytest.fixture(scope='session')
116def _dynconf_json_cache():
117 return {}
118
119
120@pytest.fixture
121def _dynconf_load_json_cached(json_loads, _dynconf_json_cache):
122 def load(path: pathlib.Path):
123 if path not in _dynconf_json_cache:
124 _dynconf_json_cache[path] = json_loads(path.read_text())
125 return _dynconf_json_cache[path]
126
127 return load
128
129
130@pytest.fixture
131def taxi_config(dynamic_config) -> dynconf.DynamicConfig:
132 """
133 Deprecated, use `dynamic_config` instead.
134 """
135 return dynamic_config
136
137
138@pytest.fixture(scope='session')
139def dynamic_config_fallback_patch() -> dynconf.ConfigValuesDict:
140 """
141 Override this fixture to replace some dynamic config values specifically
142 for testsuite tests:
143
144 @code
145 @pytest.fixture(scope='session')
146 def dynamic_config_fallback_patch():
147 return {"MY_CONFIG_NAME": 42}
148 @endcode
149
150 @ingroup userver_testsuite_fixtures
151 """
152 return {}
153
154
155@pytest.fixture(scope='session')
157 config_fallback_path,
158 dynamic_config_fallback_patch,
159) -> dynconf.ConfigValuesDict:
160 """
161 Fixture that returns default values for dynamic config. You may override
162 it in your local conftest.py or fixture:
163
164 @code
165 @pytest.fixture(scope='session')
166 def config_service_defaults():
167 with open('defaults.json') as fp:
168 return json.load(fp)
169 @endcode
170
171 @ingroup userver_testsuite_fixtures
172 """
173 if not config_fallback_path:
174 return dynamic_config_fallback_patch
175
176 if pathlib.Path(config_fallback_path).exists():
177 with open(config_fallback_path, 'r', encoding='utf-8') as file:
178 fallback = json.load(file)
179 fallback.update(dynamic_config_fallback_patch)
180 return fallback
181
182 raise RuntimeError(
183 'Invalid path specified in config_fallback_path fixture. '
184 'Probably invalid path was passed in --config-fallback pytest option.',
185 )
186
187
188@dataclasses.dataclass(frozen=False)
190 snapshot: dynconf.ConfigValuesDict | None
191
192 async def update(self, client, dynamic_config) -> None:
193 if self.snapshot is None:
194 defaults = await client.get_dynamic_config_defaults()
195 if not isinstance(defaults, dict):
197 self.snapshot = defaults
198 # pylint:disable=protected-access
199 dynamic_config._defaults = defaults
200
201
202# config_service_defaults fetches the dynamic config overrides, e.g. specified
203# in the json file, then userver_config_dynconf_fallback forwards them
204# to the service so that it has the correct dynamic config defaults.
205#
206# Independently of that, it is useful to have values for all configs, even
207# unspecified in tests, on the testsuite side. For that, we ask the service
208# for the dynamic config defaults after it's launched. It's enough to update
209# defaults once per service launch.
210@pytest.fixture(scope='session')
211def _dynamic_config_defaults_storage() -> _ConfigDefaults:
212 return _ConfigDefaults(snapshot=None)
213
214
215@pytest.fixture(scope='session')
217 """
218 Returns a function that adjusts the static configuration file for
219 the testsuite.
220 Sets `dynamic-config.fs-cache-path` to a file that is reset after the tests
221 to avoid leaking dynamic config values between test sessions.
222
223 @ingroup userver_testsuite_fixtures
224 """
225
226 def patch_config(config, _config_vars) -> None:
227 components = config['components_manager']['components']
228 dynamic_config_component = components.get('dynamic-config', None) or {}
229 if dynamic_config_component.get('fs-cache-path', '') == '':
230 return
231
232 cache_path = service_tmpdir / 'configs' / 'config_cache.json'
233
234 if cache_path.is_file():
235 # To avoid leaking dynamic config values between test sessions
236 cache_path.unlink()
237
238 dynamic_config_component['fs-cache-path'] = str(cache_path)
239
240 return patch_config
241
242
243@pytest.fixture(scope='session')
244def userver_config_dynconf_fallback(config_service_defaults):
245 """
246 Returns a function that adjusts the static configuration file for
247 the testsuite.
248 Removes `dynamic-config.defaults-path`.
249 Updates `dynamic-config.defaults` with `config_service_defaults`.
250
251 @ingroup userver_testsuite_fixtures
252 """
253
254 def extract_defaults_dict(component_config, config_vars) -> dict:
255 defaults_field = component_config.get('defaults', None) or {}
256 if isinstance(defaults_field, dict):
257 return defaults_field
258 elif isinstance(defaults_field, str):
259 if defaults_field.startswith('$'):
260 return config_vars.get(defaults_field[1:], {})
261 assert False, f'Unexpected static config option `dynamic-config.defaults`: {defaults_field!r}'
262
263 def _patch_config(config_yaml, config_vars):
264 components = config_yaml['components_manager']['components']
265 if components.get('dynamic-config', None) is None:
266 components['dynamic-config'] = {}
267 dynconf_component = components['dynamic-config']
268
269 dynconf_component.pop('defaults-path', None)
270 defaults = dict(
271 extract_defaults_dict(dynconf_component, config_vars),
272 **config_service_defaults,
273 )
274 dynconf_component['defaults'] = defaults
275
276 return _patch_config
277
278
279@pytest.fixture(scope='session')
280def userver_config_dynconf_url(mockserver_info):
281 """
282 Returns a function that adjusts the static configuration file for
283 the testsuite.
284 Sets the `dynamic-config-client.config-url` to the value of mockserver
285 configs-service, so that the
286 @ref pytest_userver.plugins.dynamic_config.mock_configs_service
287 "mock_configs_service" fixture could work.
288
289 @ingroup userver_testsuite_fixtures
290 """
291
292 def _patch_config(config, _config_vars) -> None:
293 components = config['components_manager']['components']
294 client = components.get('dynamic-config-client', None)
295 if client:
296 client['config-url'] = mockserver_info.url('configs-service')
297 client['append-path-to-url'] = True
298
299 return _patch_config
300
301
302# @cond
303
304
305# TODO publish dynconf._Changelog and document how to use it in custom config service
306# mocks.
307@pytest.fixture(scope='session')
308def dynamic_config_changelog() -> dynconf._Changelog:
309 return dynconf._Changelog()
310
311
312# @endcond
313
314
315@pytest.fixture
317 mockserver,
318 dynamic_config: dynconf.DynamicConfig,
319 dynamic_config_changelog: dynconf._Changelog,
320) -> None:
321 """
322 Adds a mockserver handler that forwards dynamic_config to service's
323 `dynamic-config-client` component.
324
325 @ingroup userver_testsuite_fixtures
326 """
327
328 @mockserver.json_handler('/configs-service/configs/values')
329 def _mock_configs(request):
330 updates = dynamic_config_changelog.get_updated_since(
331 dynconf._create_config_dict(
332 dynamic_config.get_values_unsafe(),
333 dynamic_config.get_kill_switches_disabled_unsafe(),
334 ),
335 request.json.get('updated_since', ''),
336 request.json.get('ids'),
337 )
338 response = {'configs': updates.values, 'updated_at': updates.timestamp}
339 if updates.removed:
340 response['removed'] = updates.removed
341 if updates.kill_switches_disabled:
342 response['kill_switches_disabled'] = updates.kill_switches_disabled
343 return response
344
345 @mockserver.json_handler('/configs-service/configs/status')
346 def _mock_configs_status(_request):
347 return {
348 'updated_at': dynamic_config_changelog.timestamp.strftime(
349 '%Y-%m-%dT%H:%M:%SZ',
350 ),
351 }
352
353
354@pytest.fixture
355def _userver_dynconfig_cache_control(dynamic_config_changelog: dynconf._Changelog):
356 def cache_control(updater, timestamp):
357 entry = dynamic_config_changelog.commit()
358 if entry.timestamp == timestamp:
359 updater.exclude()
360 else:
361 updater.incremental()
362 return entry.timestamp
363
364 return cache_control
365
366
367_CHECK_CONFIG_ERROR = (
368 'Your are trying to override config value using '
369 '@pytest.mark.config({}) '
370 'that does not seem to be used by your service.\n\n'
371 'In case you really need to disable this check please add the '
372 'following mark to your testcase:\n\n'
373 '@pytest.mark.disable_config_check'
374)
375
376
377# Should be invoked after _dynamic_config_defaults_storage is filled.
378@pytest.fixture
379def _check_config_marks(
380 request,
381 _dynamic_config_defaults_storage,
382) -> Callable[[], None]:
383 def check():
384 config_defaults = _dynamic_config_defaults_storage.snapshot
385 assert config_defaults is not None
386
387 if request.node.get_closest_marker('disable_config_check'):
388 return
389
390 unknown_configs = [
391 key for marker in request.node.iter_markers('config') for key in marker.kwargs if key not in config_defaults
392 ]
393
394 if unknown_configs:
395 message = _CHECK_CONFIG_ERROR.format(
396 ', '.join(f'{key}=...' for key in sorted(unknown_configs)),
397 )
398 raise dynconf.UnknownConfigError(message)
399
400 return check