userver: /data/code/service_template/third_party/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
6import contextlib
7import copy
8import dataclasses
9import datetime
10import json
11import pathlib
12import typing
13
14import pytest
15
16from pytest_userver.plugins import caches
17
18USERVER_CONFIG_HOOKS = [
19 'userver_config_dynconf_cache',
20 'userver_config_dynconf_fallback',
21 'userver_config_dynconf_url',
22]
23USERVER_CACHE_CONTROL_HOOKS = {
24 'dynamic-config-client-updater': '_userver_dynconfig_cache_control',
25}
26
27_CONFIG_CACHES = tuple(USERVER_CACHE_CONTROL_HOOKS.keys())
28
29
30class BaseError(Exception):
31 """Base class for exceptions from this module"""
32
33
35 """Config parameter was not found and no default was provided"""
36
37
39 """Dynamic config defaults action returned invalid response"""
40
41
42ConfigDict = typing.Dict[str, typing.Any]
43
44
46 pass
47
48
49class Missing:
50 pass
51
52
53@dataclasses.dataclass
55 timestamp: str
56 dirty_keys: typing.Set[str]
57 state: ConfigDict
58
59 @classmethod
60 def new(
61 cls,
62 *,
63 previous: typing.Optional['_ChangelogEntry'],
64 timestamp: str,
65 ):
66 if previous:
67 state = previous.state.copy()
68 else:
69 state = {}
70 return cls(timestamp=timestamp, dirty_keys=set(), state=state)
71
72 @property
73 def has_changes(self) -> bool:
74 return bool(self.dirty_keysdirty_keys)
75
76 def update(self, values: ConfigDict):
77 self.state.update(values)
78 self.dirty_keysdirty_keys.update(values.keys())
79
80
81@dataclasses.dataclass(frozen=True)
82class Updates:
83 timestamp: str
84 values: ConfigDict
85 removed: typing.List[str]
86
87 def is_empty(self) -> bool:
88 return not self.values and not self.removed
89
90
92 entry: _ChangelogEntry
93 entries: typing.List[_ChangelogEntry]
94
95 def __init__(self):
96 self.timestamp = datetime.datetime.fromtimestamp(
97 0, datetime.timezone.utc,
98 )
99 self.last_entry = _ChangelogEntry.new(
100 timestamp=self.service_timestamp(), previous=None,
101 )
102 self.entriesentries = [self.last_entry]
103
104 def service_timestamp(self) -> str:
105 return self.timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
106
107 def next_timestamp(self) -> str:
108 self.timestamp += datetime.timedelta(seconds=1)
109 return self.service_timestamp()
110
111 def tick(self):
112 self.last_entry = _ChangelogEntry.new(
113 timestamp=self.next_timestamp(), previous=self.last_entry,
114 )
115 self.entriesentries.append(self.last_entry)
116
117 def get_updated_since(
118 self,
119 values: ConfigDict,
120 updated_since: str,
121 ids: typing.Optional[typing.List[str]] = None,
122 ) -> Updates:
123 entry = self.last_entry
124 values, removed = self._get_updated_since(values, updated_since)
125 self.tick()
126 if ids:
127 values = {name: values[name] for name in ids if name in values}
128 removed = [name for name in removed if name in ids]
129 return Updates(
130 timestamp=entry.timestamp, values=values, removed=removed,
131 )
132
133 def _get_updated_since(
134 self, values: ConfigDict, updated_since: str,
135 ) -> typing.Tuple[ConfigDict, typing.List[str]]:
136 if not updated_since:
137 return values, []
138 dirty_keys = set()
139 last_known_state = {}
140 for entry in reversed(self.entriesentries):
141 if entry.timestamp > updated_since:
142 dirty_keys.update(entry.dirty_keys)
143 else:
144 last_known_state = entry.state
145 break
146 # We don't want to send them again
147 result = {}
148 removed = []
149 for key in dirty_keys:
150 value = values.get(key, RemoveKey)
151 if last_known_state.get(key, Missing) != value:
152 if value is RemoveKey:
153 removed.append(key)
154 else:
155 result[key] = value
156 return result, removed
157
158 def add_entries(self, values: ConfigDict):
159 self.last_entry.update(values)
160
161 @contextlib.contextmanager
162 def rollback(self, defaults: ConfigDict):
163 index = len(self.entriesentries) - 1
164 try:
165 yield
166 finally:
167 self._do_rollback(index, defaults)
168
169 def _do_rollback(self, first_index: int, defaults: ConfigDict):
170 dirty_keys = set()
171 for index in range(first_index, len(self.entriesentries)):
172 dirty_keys.update(self.entriesentries[index].dirty_keys)
173 if self.last_entry.has_changes:
174 self.tick()
175
176 changes = {key: defaults.get(key, RemoveKey) for key in dirty_keys}
177 self.add_entries(changes)
178 self.entriesentries = self.entriesentries[-2:]
179
180
182 """Simple dynamic config backend."""
183
184 def __init__(
185 self,
186 *,
187 initial_values: ConfigDict,
188 config_cache_components: typing.Iterable[str],
189 cache_invalidation_state: caches.InvalidationState,
190 changelog: _Changelog,
191 ):
192 self._values = initial_values.copy()
193 self._cache_invalidation_state = cache_invalidation_state
194 self._config_cache_components = config_cache_components
195 self._changelog = changelog
196
197 def set_values(self, values: ConfigDict):
198 self.set_values_unsafe(copy.deepcopy(values))
199
200 def set_values_unsafe(self, values: ConfigDict):
201 self._values.update(values)
202 self._changelog.add_entries(values)
203 self._sync_with_service()
204
205 def set(self, **values):
206 self.set_values(values)
207
208 def get_values_unsafe(self) -> ConfigDict:
209 return self._values
210
211 def get(self, key: str, default: typing.Any = None) -> typing.Any:
212 if key not in self._values:
213 if default is not None:
214 return default
215 raise DynamicConfigNotFoundError(f'Config {key!r} is not found')
216 return copy.deepcopy(self._values[key])
217
218 def remove_values(self, keys):
219 extra_keys = set(keys).difference(self._values.keys())
220 if extra_keys:
222 f'Attempting to remove nonexistent configs: {extra_keys}',
223 )
224 for key in keys:
225 self._values.pop(key)
226 self._changelog.add_entries({key: RemoveKey for key in keys})
227 self._sync_with_service()
228
229 def remove(self, key):
230 return self.remove_values([key])
231
232 @contextlib.contextmanager
233 def modify(self, key: str) -> typing.Any:
234 value = self.get(key)
235 yield value
236 self.set_values({key: value})
237
238 @contextlib.contextmanager
239 def modify_many(
240 self, *keys: typing.Tuple[str, ...],
241 ) -> typing.Tuple[typing.Any, ...]:
242 values = tuple(self.get(key) for key in keys)
243 yield values
244 self.set_values(dict(zip(keys, values)))
245
246 def _sync_with_service(self):
247 self._cache_invalidation_state.invalidate(
249 )
250
251
252@pytest.fixture
254 request,
255 search_path,
256 object_substitute,
257 cache_invalidation_state,
258 _config_service_defaults_updated,
259 dynamic_config_changelog,
260 _dynconfig_load_json_cached,
261 dynconf_cache_names,
262) -> DynamicConfig:
263 """
264 Fixture that allows to control dynamic config values used by the service.
265
266 After change to the config, be sure to call:
267 @code
268 await service_client.update_server_state()
269 @endcode
270
271 HTTP client requests call it automatically before each request.
272
273 @ingroup userver_testsuite_fixtures
274 """
275 config = DynamicConfig(
276 initial_values=_config_service_defaults_updated.snapshot,
277 config_cache_components=dynconf_cache_names,
278 cache_invalidation_state=cache_invalidation_state,
279 changelog=dynamic_config_changelog,
280 )
281 updates = {}
282 with dynamic_config_changelog.rollback(
283 _config_service_defaults_updated.snapshot,
284 ):
285 for path in reversed(list(search_path('config.json'))):
286 values = _dynconfig_load_json_cached(path)
287 updates.update(values)
288 for marker in request.node.iter_markers('config'):
289 marker_json = object_substitute(marker.kwargs)
290 updates.update(marker_json)
291 config.set_values_unsafe(updates)
292 yield config
293
294
295@pytest.fixture(scope='session')
296def dynconf_cache_names():
297 return tuple(_CONFIG_CACHES)
298
299
300@pytest.fixture(scope='session')
301def _dynconfig_json_cache():
302 return {}
303
304
305@pytest.fixture
306def _dynconfig_load_json_cached(json_loads, _dynconfig_json_cache):
307 def load(path: pathlib.Path):
308 if path not in _dynconfig_json_cache:
309 _dynconfig_json_cache[path] = json_loads(path.read_text())
310 return _dynconfig_json_cache[path]
311
312 return load
313
314
315@pytest.fixture
316def taxi_config(dynamic_config) -> DynamicConfig:
317 """
318 Deprecated, use `dynamic_config` instead.
319 """
320 return dynamic_config
321
322
323@pytest.fixture(scope='session')
325 """
326 Override this fixture to replace some dynamic config values specifically
327 for testsuite tests:
328
329 @code
330 @pytest.fixture(scope='session')
331 def dynamic_config_fallback_patch():
332 return {"MY_CONFIG_NAME": 42}
333 @endcode
334
335 @ingroup userver_testsuite_fixtures
336 """
337 return {}
338
339
340@pytest.fixture(scope='session')
342 config_fallback_path, dynamic_config_fallback_patch,
343) -> ConfigDict:
344 """
345 Fixture that returns default values for dynamic config. You may override
346 it in your local conftest.py or fixture:
347
348 @code
349 @pytest.fixture(scope='session')
350 def config_service_defaults():
351 with open('defaults.json') as fp:
352 return json.load(fp)
353 @endcode
354
355 @ingroup userver_testsuite_fixtures
356 """
357 if not config_fallback_path:
358 return dynamic_config_fallback_patch
359
360 if pathlib.Path(config_fallback_path).exists():
361 with open(config_fallback_path, 'r', encoding='utf-8') as file:
362 fallback = json.load(file)
363 fallback.update(dynamic_config_fallback_patch)
364 return fallback
365
366 raise RuntimeError(
367 'Either provide the path to dynamic config defaults file using '
368 '--config-fallback pytest option, or override '
369 f'{config_service_defaults.__name__} fixture to provide custom '
370 'dynamic config loading behavior.',
371 )
372
373
374@dataclasses.dataclass(frozen=False)
376 snapshot: ConfigDict
377
378 async def update(self, client, dynamic_config) -> None:
379 if not self.snapshotsnapshot:
380 values = await client.get_dynamic_config_defaults()
381 if not isinstance(values, dict):
383 # There may already be some config overrides from the current test.
384 values.update(dynamic_config.get_values_unsafe())
385 self.snapshotsnapshot = values
386 dynamic_config.set_values(self.snapshotsnapshot)
387
388
389# If there is no config_fallback_path, then we want to ask the service
390# for the dynamic config defaults after it's launched. It's enough to update
391# defaults once per service launch.
392@pytest.fixture(scope='package')
393def _config_service_defaults_updated(config_service_defaults):
394 return _ConfigDefaults(snapshot=config_service_defaults)
395
396
397@pytest.fixture(scope='session')
398def userver_config_dynconf_cache(service_tmpdir):
399 def patch_config(config, _config_vars) -> None:
400 components = config['components_manager']['components']
401 dynamic_config_component = components.get('dynamic-config', None) or {}
402 if dynamic_config_component.get('fs-cache-path', '') == '':
403 return
404
405 cache_path = service_tmpdir / 'configs' / 'config_cache.json'
406
407 if cache_path.is_file():
408 # To avoid leaking dynamic config values between test sessions
409 cache_path.unlink()
410
411 dynamic_config_component['fs-cache-path'] = str(cache_path)
412
413 return patch_config
414
415
416@pytest.fixture(scope='session')
417def userver_config_dynconf_fallback(config_service_defaults):
418 """
419 Returns a function that adjusts the static configuration file for
420 the testsuite.
421 Sets `dynamic-config.defaults-path` according to `config_service_defaults`.
422 Updates `dynamic-config.defaults` with `config_service_defaults`.
423
424 @ingroup userver_testsuite_fixtures
425 """
426
427 def extract_defaults_dict(component_config, config_vars) -> dict:
428 defaults_field = component_config.get('defaults', None) or {}
429 if isinstance(defaults_field, dict):
430 return defaults_field
431 elif isinstance(defaults_field, str):
432 if defaults_field.startswith('$'):
433 return config_vars.get(defaults_field[1:], {})
434 assert False, (
435 f'Unexpected static config option '
436 f'`dynamic-config.defaults`: {defaults_field!r}'
437 )
438
439 def _patch_config(config_yaml, config_vars):
440 components = config_yaml['components_manager']['components']
441 if components.get('dynamic-config', None) is None:
442 components['dynamic-config'] = {}
443 dynconf_component = components['dynamic-config']
444
445 dynconf_component.pop('defaults-path', None)
446 defaults = dict(
447 extract_defaults_dict(dynconf_component, config_vars),
448 **config_service_defaults,
449 )
450 dynconf_component['defaults'] = defaults
451
452 return _patch_config
453
454
455@pytest.fixture(scope='session')
456def userver_config_dynconf_url(mockserver_info):
457 """
458 Returns a function that adjusts the static configuration file for
459 the testsuite.
460 Sets the `dynamic-config-client.config-url` to the value of mockserver
461 configs-service, so that the
462 @ref pytest_userver.plugins.dynamic_config.mock_configs_service
463 "mock_configs_service" fixture could work.
464
465 @ingroup userver_testsuite_fixtures
466 """
467
468 def _patch_config(config, _config_vars) -> None:
469 components = config['components_manager']['components']
470 client = components.get('dynamic-config-client', None)
471 if client:
472 client['config-url'] = mockserver_info.url('configs-service')
473 client['append-path-to-url'] = True
474
475 return _patch_config
476
477
478# TODO publish _Changelog and document how to use it in custom config service
479# mocks.
480@pytest.fixture(scope='session')
481def dynamic_config_changelog() -> _Changelog:
482 return _Changelog()
483
484
485@pytest.fixture
487 mockserver,
488 dynamic_config: DynamicConfig,
489 dynamic_config_changelog: _Changelog,
490) -> None:
491 """
492 Adds a mockserver handler that forwards dynamic_config to service's
493 `dynamic-config-client` component.
494
495 @ingroup userver_testsuite_fixtures
496 """
497
498 @mockserver.json_handler('/configs-service/configs/values')
499 def _mock_configs(request):
500 updates = dynamic_config_changelog.get_updated_since(
501 dynamic_config.get_values_unsafe(),
502 request.json.get('updated_since', ''),
503 request.json.get('ids'),
504 )
505 response = {'configs': updates.values, 'updated_at': updates.timestamp}
506 if updates.removed:
507 response['removed'] = updates.removed
508 return response
509
510 @mockserver.json_handler('/configs-service/configs/status')
511 def _mock_configs_status(_request):
512 return {'updated_at': dynamic_config_changelog.timestamp}
513
514
515@pytest.fixture
516def _userver_dynconfig_cache_control(dynamic_config, dynamic_config_changelog):
517 def cache_control(updater, timestamp):
518 current_timestamp = dynamic_config_changelog.last_entry.timestamp
519 if timestamp:
520 updates = dynamic_config_changelog.get_updated_since(
521 dynamic_config.get_values_unsafe(), timestamp,
522 )
523 if updates.is_empty():
524 updater.exclude()
525 return timestamp
526 updater.incremental()
527 return current_timestamp
528
529 return cache_control