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
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
28class BaseError(Exception):
29 """Base class for exceptions from this module"""
30
31
33 """Config parameter was not found and no default was provided"""
34
35
37 """
38 Calling `dynamic_config.get` before defaults are fetched from the service.
39 Try adding a dependency on `service_client` in your fixture.
40 """
41
42
44 """Dynamic config defaults action returned invalid response"""
45
46
48 """Invalid dynamic config name in @pytest.mark.config"""
49
50
51ConfigDict = typing.Dict[str, typing.Any]
52
53
55 pass
56
57
58class Missing:
59 pass
60
61
62@dataclasses.dataclass
64 timestamp: str
65 dirty_keys: typing.Set[str]
66 state: ConfigDict
67 prev_state: ConfigDict
68
69 @classmethod
70 def new(
71 cls,
72 *,
73 previous: typing.Optional['_ChangelogEntry'],
74 timestamp: str,
75 ):
76 if previous:
77 prev_state = previous.state
78 else:
79 prev_state = {}
80 return cls(
81 timestamp=timestamp,
82 dirty_keys=set(),
83 state=prev_state.copy(),
84 prev_state=prev_state,
85 )
86
87 @property
88 def has_changes(self) -> bool:
89 return bool(self.dirty_keysdirty_keys)
90
91 def update(self, values: ConfigDict):
92 for key, value in values.items():
93 if value == self.prev_state.get(key, Missing):
94 self.dirty_keysdirty_keys.discard(key)
95 else:
96 self.dirty_keysdirty_keys.add(key)
97 self.state.update(values)
98
99
100@dataclasses.dataclass(frozen=True)
102 timestamp: str
103 values: ConfigDict
104 removed: typing.List[str]
105
106 def is_empty(self) -> bool:
107 return not self.values and not self.removed
108
109
111 timestamp: datetime.datetime
112 commited_entries: typing.List[_ChangelogEntry]
113 staged_entry: _ChangelogEntry
114
115 def __init__(self):
116 self.timestamptimestamp = datetime.datetime.fromtimestamp(
117 0, datetime.timezone.utc,
118 )
120 self.staged_entrystaged_entry = _ChangelogEntry.new(
121 timestamp=self.service_timestamp(), previous=None,
122 )
123
124 def service_timestamp(self) -> str:
125 return self.timestamptimestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
126
127 def next_timestamp(self) -> str:
128 self.timestamptimestamp += datetime.timedelta(seconds=1)
129 return self.service_timestamp()
130
131 def commit(self) -> _ChangelogEntry:
132 """Commit staged changed if any and return last commited entry."""
133 entry = self.staged_entrystaged_entry
134 if entry.has_changes or not self.commited_entriescommited_entries:
135 self.staged_entrystaged_entry = _ChangelogEntry.new(
136 timestamp=self.next_timestamp(), previous=entry,
137 )
138 self.commited_entriescommited_entries.append(entry)
140
141 def get_updated_since(
142 self,
143 values: ConfigDict,
144 updated_since: str,
145 ids: typing.Optional[typing.List[str]] = None,
146 ) -> Updates:
147 entry = self.commit()
148 values, removed = self._get_updated_since(values, updated_since)
149 if ids:
150 values = {name: values[name] for name in ids if name in values}
151 removed = [name for name in removed if name in ids]
152 return Updates(
153 timestamp=entry.timestamp, values=values, removed=removed,
154 )
155
156 def _get_updated_since(
157 self, values: ConfigDict, updated_since: str,
158 ) -> typing.Tuple[ConfigDict, typing.List[str]]:
159 if not updated_since:
160 return values, []
161 dirty_keys = set()
162 last_known_state = {}
163 for entry in reversed(self.commited_entriescommited_entries):
164 if entry.timestamp > updated_since:
165 dirty_keys.update(entry.dirty_keys)
166 else:
167 if entry.timestamp == updated_since:
168 last_known_state = entry.state
169 break
170 # We don't want to send them again
171 result = {}
172 removed = []
173 for key in dirty_keys:
174 value = values.get(key, RemoveKey)
175 if last_known_state.get(key, Missing) != value:
176 if value is RemoveKey:
177 removed.append(key)
178 else:
179 result[key] = value
180 return result, removed
181
182 def add_entries(self, values: ConfigDict):
183 self.staged_entrystaged_entry.update(values)
184
185 @contextlib.contextmanager
186 def rollback(self, defaults: ConfigDict):
187 try:
188 yield
189 finally:
190 self._do_rollback(defaults)
191
192 def _do_rollback(self, defaults: ConfigDict):
194 return
195
196 maybe_dirty = set()
197 for entry in self.commited_entriescommited_entries:
198 maybe_dirty.update(entry.dirty_keys)
199
201 last_state = last.state
202 dirty_keys = set()
203 reverted = {}
204 for key in maybe_dirty:
205 original = defaults.get(key, RemoveKey)
206 if last_state[key] != original:
207 dirty_keys.add(key)
208 reverted[key] = original
209
210 entry = _ChangelogEntry(
211 timestamp=last.timestamp,
212 state=last.state,
213 dirty_keys=dirty_keys,
214 prev_state={},
215 )
218 timestamp=self.staged_entrystaged_entry.timestamp,
219 dirty_keys=dirty_keys.copy(),
220 state=reverted,
221 prev_state=entry.state,
222 )
223
224
226 """Simple dynamic config backend."""
227
228 def __init__(
229 self,
230 *,
231 initial_values: ConfigDict,
232 defaults: typing.Optional[ConfigDict],
233 config_cache_components: typing.Iterable[str],
234 cache_invalidation_state: caches.InvalidationState,
235 changelog: _Changelog,
236 ):
237 self._values = initial_values.copy()
238 # Defaults are only there for convenience, to allow accessing them
239 # in tests using dynamic_config.get. They are not sent to the service.
240 self._defaults = defaults
241 self._cache_invalidation_state = cache_invalidation_state
242 self._config_cache_components = config_cache_components
243 self._changelog = changelog
244
245 def set_values(self, values: ConfigDict):
246 self.set_values_unsafe(copy.deepcopy(values))
247
248 def set_values_unsafe(self, values: ConfigDict):
249 self._values.update(values)
250 self._changelog.add_entries(values)
251 self._sync_with_service()
252
253 def set(self, **values):
254 self.set_values(values)
255
256 def get_values_unsafe(self) -> ConfigDict:
257 return self._values
258
259 def get(self, key: str, default: typing.Any = None) -> typing.Any:
260 if key in self._values:
261 return copy.deepcopy(self._values[key])
262 if self._defaults is not None and key in self._defaults:
263 return copy.deepcopy(self._defaults[key])
264 if default is not None:
265 return default
266 if self._defaults is None:
268 f'Defaults for config {key!r} have not yet been fetched '
269 'from the service. Options:\n'
270 '1. add a dependency on service_client in your fixture;\n'
271 '2. pass `default` parameter to `dynamic_config.get`',
272 )
273 raise DynamicConfigNotFoundError(f'Config {key!r} is not found')
274
275 def remove_values(self, keys):
276 extra_keys = set(keys).difference(self._values.keys())
277 if extra_keys:
279 f'Attempting to remove nonexistent configs: {extra_keys}',
280 )
281 for key in keys:
282 self._values.pop(key)
283 self._changelog.add_entries({key: RemoveKey for key in keys})
284 self._sync_with_service()
285
286 def remove(self, key):
287 return self.remove_values([key])
288
289 @contextlib.contextmanager
290 def modify(self, key: str) -> typing.Any:
291 value = self.get(key)
292 yield value
293 self.set_values({key: value})
294
295 @contextlib.contextmanager
296 def modify_many(
297 self, *keys: typing.Tuple[str, ...],
298 ) -> typing.Tuple[typing.Any, ...]:
299 values = tuple(self.get(key) for key in keys)
300 yield values
301 self.set_values(dict(zip(keys, values)))
302
303 def _sync_with_service(self):
304 self._cache_invalidation_state.invalidate(
306 )
307
308
309@pytest.fixture
311 request,
312 search_path,
313 object_substitute,
314 cache_invalidation_state,
315 _dynamic_config_defaults_storage,
316 config_service_defaults,
317 dynamic_config_changelog,
318 _dynconf_load_json_cached,
319 dynconf_cache_names,
320) -> DynamicConfig:
321 """
322 Fixture that allows to control dynamic config values used by the service.
323
324 After change to the config, be sure to call:
325 @code
326 await service_client.update_server_state()
327 @endcode
328
329 HTTP client requests call it automatically before each request.
330
331 @ingroup userver_testsuite_fixtures
332 """
333 config = DynamicConfig(
334 initial_values=config_service_defaults,
335 defaults=_dynamic_config_defaults_storage.snapshot,
336 config_cache_components=dynconf_cache_names,
337 cache_invalidation_state=cache_invalidation_state,
338 changelog=dynamic_config_changelog,
339 )
340
341 with dynamic_config_changelog.rollback(config_service_defaults):
342 updates = {}
343 for path in reversed(list(search_path('config.json'))):
344 values = _dynconf_load_json_cached(path)
345 updates.update(values)
346 for marker in request.node.iter_markers('config'):
347 marker_json = object_substitute(marker.kwargs)
348 updates.update(marker_json)
349 config.set_values_unsafe(updates)
350 yield config
351
352
353def pytest_configure(config):
354 config.addinivalue_line(
355 'markers', 'config: per-test dynamic config values',
356 )
357 config.addinivalue_line(
358 'markers', 'disable_config_check: disable config mark keys check',
359 )
360
361
362@pytest.fixture(scope='session')
363def dynconf_cache_names() -> typing.Iterable[str]:
364 return tuple(USERVER_CACHE_CONTROL_HOOKS.keys())
365
366
367@pytest.fixture(scope='session')
368def _dynconf_json_cache():
369 return {}
370
371
372@pytest.fixture
373def _dynconf_load_json_cached(json_loads, _dynconf_json_cache):
374 def load(path: pathlib.Path):
375 if path not in _dynconf_json_cache:
376 _dynconf_json_cache[path] = json_loads(path.read_text())
377 return _dynconf_json_cache[path]
378
379 return load
380
381
382@pytest.fixture
383def taxi_config(dynamic_config) -> DynamicConfig:
384 """
385 Deprecated, use `dynamic_config` instead.
386 """
387 return dynamic_config
388
389
390@pytest.fixture(scope='session')
392 """
393 Override this fixture to replace some dynamic config values specifically
394 for testsuite tests:
395
396 @code
397 @pytest.fixture(scope='session')
398 def dynamic_config_fallback_patch():
399 return {"MY_CONFIG_NAME": 42}
400 @endcode
401
402 @ingroup userver_testsuite_fixtures
403 """
404 return {}
405
406
407@pytest.fixture(scope='session')
409 config_fallback_path, dynamic_config_fallback_patch,
410) -> ConfigDict:
411 """
412 Fixture that returns default values for dynamic config. You may override
413 it in your local conftest.py or fixture:
414
415 @code
416 @pytest.fixture(scope='session')
417 def config_service_defaults():
418 with open('defaults.json') as fp:
419 return json.load(fp)
420 @endcode
421
422 @ingroup userver_testsuite_fixtures
423 """
424 if not config_fallback_path:
425 return dynamic_config_fallback_patch
426
427 if pathlib.Path(config_fallback_path).exists():
428 with open(config_fallback_path, 'r', encoding='utf-8') as file:
429 fallback = json.load(file)
430 fallback.update(dynamic_config_fallback_patch)
431 return fallback
432
433 raise RuntimeError(
434 'Invalid path specified in config_fallback_path fixture. '
435 'Probably invalid path was passed in --config-fallback pytest option.',
436 )
437
438
439@dataclasses.dataclass(frozen=False)
441 snapshot: typing.Optional[ConfigDict]
442
443 async def update(self, client, dynamic_config) -> None:
444 if self.snapshotsnapshot is None:
445 defaults = await client.get_dynamic_config_defaults()
446 if not isinstance(defaults, dict):
448 self.snapshotsnapshot = defaults
449 # pylint:disable=protected-access
450 dynamic_config._defaults = defaults
451
452
453# config_service_defaults fetches the dynamic config overrides, e.g. specified
454# in the json file, then userver_config_dynconf_fallback forwards them
455# to the service so that it has the correct dynamic config defaults.
456#
457# Independently of that, it is useful to have values for all configs, even
458# unspecified in tests, on the testsuite side. For that, we ask the service
459# for the dynamic config defaults after it's launched. It's enough to update
460# defaults once per service launch.
461@pytest.fixture(scope='package')
462def _dynamic_config_defaults_storage() -> _ConfigDefaults:
463 return _ConfigDefaults(snapshot=None)
464
465
466@pytest.fixture(scope='session')
468 """
469 Returns a function that adjusts the static configuration file for
470 the testsuite.
471 Sets `dynamic-config.fs-cache-path` to a file that is reset after the tests
472 to avoid leaking dynamic config values between test sessions.
473
474 @ingroup userver_testsuite_fixtures
475 """
476
477 def patch_config(config, _config_vars) -> None:
478 components = config['components_manager']['components']
479 dynamic_config_component = components.get('dynamic-config', None) or {}
480 if dynamic_config_component.get('fs-cache-path', '') == '':
481 return
482
483 cache_path = service_tmpdir / 'configs' / 'config_cache.json'
484
485 if cache_path.is_file():
486 # To avoid leaking dynamic config values between test sessions
487 cache_path.unlink()
488
489 dynamic_config_component['fs-cache-path'] = str(cache_path)
490
491 return patch_config
492
493
494@pytest.fixture(scope='session')
495def userver_config_dynconf_fallback(config_service_defaults):
496 """
497 Returns a function that adjusts the static configuration file for
498 the testsuite.
499 Removes `dynamic-config.defaults-path`.
500 Updates `dynamic-config.defaults` with `config_service_defaults`.
501
502 @ingroup userver_testsuite_fixtures
503 """
504
505 def extract_defaults_dict(component_config, config_vars) -> dict:
506 defaults_field = component_config.get('defaults', None) or {}
507 if isinstance(defaults_field, dict):
508 return defaults_field
509 elif isinstance(defaults_field, str):
510 if defaults_field.startswith('$'):
511 return config_vars.get(defaults_field[1:], {})
512 assert False, (
513 f'Unexpected static config option '
514 f'`dynamic-config.defaults`: {defaults_field!r}'
515 )
516
517 def _patch_config(config_yaml, config_vars):
518 components = config_yaml['components_manager']['components']
519 if components.get('dynamic-config', None) is None:
520 components['dynamic-config'] = {}
521 dynconf_component = components['dynamic-config']
522
523 dynconf_component.pop('defaults-path', None)
524 defaults = dict(
525 extract_defaults_dict(dynconf_component, config_vars),
526 **config_service_defaults,
527 )
528 dynconf_component['defaults'] = defaults
529
530 return _patch_config
531
532
533@pytest.fixture(scope='session')
534def userver_config_dynconf_url(mockserver_info):
535 """
536 Returns a function that adjusts the static configuration file for
537 the testsuite.
538 Sets the `dynamic-config-client.config-url` to the value of mockserver
539 configs-service, so that the
540 @ref pytest_userver.plugins.dynamic_config.mock_configs_service
541 "mock_configs_service" fixture could work.
542
543 @ingroup userver_testsuite_fixtures
544 """
545
546 def _patch_config(config, _config_vars) -> None:
547 components = config['components_manager']['components']
548 client = components.get('dynamic-config-client', None)
549 if client:
550 client['config-url'] = mockserver_info.url('configs-service')
551 client['append-path-to-url'] = True
552
553 return _patch_config
554
555
556# TODO publish _Changelog and document how to use it in custom config service
557# mocks.
558@pytest.fixture(scope='session')
559def dynamic_config_changelog() -> _Changelog:
560 return _Changelog()
561
562
563@pytest.fixture
565 mockserver,
566 dynamic_config: DynamicConfig,
567 dynamic_config_changelog: _Changelog,
568) -> None:
569 """
570 Adds a mockserver handler that forwards dynamic_config to service's
571 `dynamic-config-client` component.
572
573 @ingroup userver_testsuite_fixtures
574 """
575
576 @mockserver.json_handler('/configs-service/configs/values')
577 def _mock_configs(request):
578 updates = dynamic_config_changelog.get_updated_since(
579 dynamic_config.get_values_unsafe(),
580 request.json.get('updated_since', ''),
581 request.json.get('ids'),
582 )
583 response = {'configs': updates.values, 'updated_at': updates.timestamp}
584 if updates.removed:
585 response['removed'] = updates.removed
586 return response
587
588 @mockserver.json_handler('/configs-service/configs/status')
589 def _mock_configs_status(_request):
590 return {
591 'updated_at': dynamic_config_changelog.timestamp.strftime(
592 '%Y-%m-%dT%H:%M:%SZ',
593 ),
594 }
595
596
597@pytest.fixture
598def _userver_dynconfig_cache_control(dynamic_config_changelog: _Changelog):
599 def cache_control(updater, timestamp):
600 entry = dynamic_config_changelog.commit()
601 if entry.timestamp == timestamp:
602 updater.exclude()
603 else:
604 updater.incremental()
605 return entry.timestamp
606
607 return cache_control
608
609
610_CHECK_CONFIG_ERROR = (
611 'Your are trying to override config value using '
612 '@pytest.mark.config({}) '
613 'that does not seem to be used by your service.\n\n'
614 'In case you really need to disable this check please add the '
615 'following mark to your testcase:\n\n'
616 '@pytest.mark.disable_config_check'
617)
618
619
620# Should be invoked after _dynamic_config_defaults_storage is filled.
621@pytest.fixture
622def _check_config_marks(
623 request, _dynamic_config_defaults_storage,
624) -> typing.Callable[[], None]:
625 def check():
626 config_defaults = _dynamic_config_defaults_storage.snapshot
627 assert config_defaults is not None
628
629 if request.node.get_closest_marker('disable_config_check'):
630 return
631
632 unknown_configs = [
633 key
634 for marker in request.node.iter_markers('config')
635 for key in marker.kwargs
636 if key not in config_defaults
637 ]
638
639 if unknown_configs:
640 message = _CHECK_CONFIG_ERROR.format(
641 ', '.join(f'{key}=...' for key in sorted(unknown_configs)),
642 )
643 raise UnknownConfigError(message)
644
645 return check