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