65 dirty_keys: typing.Set[str]
67 prev_state: ConfigDict
73 previous: typing.Optional[
'_ChangelogEntry'],
77 prev_state = previous.state
83 state=prev_state.copy(),
84 prev_state=prev_state,
88 def has_changes(self) -> bool:
91 def update(self, values: ConfigDict):
92 for key, value
in values.items():
93 if value == self.prev_state.get(key, Missing):
97 self.state.update(values)
100@dataclasses.dataclass(frozen=True)
111 timestamp: datetime.datetime
112 committed_entries: typing.List[_ChangelogEntry]
113 staged_entry: _ChangelogEntry
116 self.
timestamp = datetime.datetime.fromtimestamp(
118 datetime.timezone.utc,
126 def service_timestamp(self) -> str:
127 return self.
timestamp.strftime(
'%Y-%m-%dT%H:%M:%SZ')
129 def next_timestamp(self) -> str:
130 self.
timestamp += datetime.timedelta(seconds=1)
134 """Commit staged changed if any and return last committed entry."""
144 def get_updated_since(
148 ids: typing.Optional[typing.List[str]] =
None,
153 values = {name: values[name]
for name
in ids
if name
in values}
154 removed = [name
for name
in removed
if name
in ids]
156 timestamp=entry.timestamp,
161 def _get_updated_since(
165 ) -> typing.Tuple[ConfigDict, typing.List[str]]:
166 if not updated_since:
169 last_known_state = {}
171 if entry.timestamp > updated_since:
172 dirty_keys.update(entry.dirty_keys)
174 if entry.timestamp == updated_since:
175 last_known_state = entry.state
180 for key
in dirty_keys:
181 value = values.get(key, RemoveKey)
182 if last_known_state.get(key, Missing) != value:
183 if value
is RemoveKey:
187 return result, removed
189 def add_entries(self, values: ConfigDict):
192 @contextlib.contextmanager
193 def rollback(self, defaults: ConfigDict):
199 def _do_rollback(self, defaults: ConfigDict):
205 maybe_dirty.update(entry.dirty_keys)
208 last_state = last.state
211 for key
in maybe_dirty:
212 original = defaults.get(key, RemoveKey)
213 if last_state[key] != original:
215 reverted[key] = original
218 timestamp=last.timestamp,
220 dirty_keys=dirty_keys,
226 dirty_keys=dirty_keys.copy(),
228 prev_state=entry.state,
234 @brief Simple dynamic config backend.
236 @see @ref pytest_userver.plugins.dynamic_config.dynamic_config "dynamic_config"
242 initial_values: ConfigDict,
243 defaults: typing.Optional[ConfigDict],
244 config_cache_components: typing.Iterable[str],
246 changelog: _Changelog,
248 self.
_values = initial_values.copy()
256 def set_values(self, values: ConfigDict):
259 def set_values_unsafe(self, values: ConfigDict):
264 def set(self, **values):
267 def get_values_unsafe(self) -> ConfigDict:
270 def get(self, key: str, default: typing.Any =
None) -> typing.Any:
272 return copy.deepcopy(self.
_values[key])
274 return copy.deepcopy(self.
_defaults[key])
275 if default
is not None:
279 f
'Defaults for config {key!r} have not yet been fetched '
280 'from the service. Options:\n'
281 '1. add a dependency on service_client in your fixture;\n'
282 '2. pass `default` parameter to `dynamic_config.get`',
286 def remove_values(self, keys):
287 extra_keys = set(keys).difference(self.
_values.keys())
290 f
'Attempting to remove nonexistent configs: {extra_keys}',
294 self.
_changelog.add_entries({key: RemoveKey
for key
in keys})
297 def remove(self, key):
300 @contextlib.contextmanager
301 def modify(self, key: str) -> typing.Any:
302 value = self.
get(key)
306 @contextlib.contextmanager
309 *keys: typing.Tuple[str, ...],
310 ) -> typing.Tuple[typing.Any, ...]:
311 values = tuple(self.
get(key)
for key
in keys)
315 def _sync_with_service(self):
326 cache_invalidation_state,
327 _dynamic_config_defaults_storage,
328 config_service_defaults,
329 dynamic_config_changelog,
330 _dynconf_load_json_cached,
334 Fixture that allows to control dynamic config values used by the service.
338 @snippet core/functional_tests/basic_chaos/tests-nonchaos/handlers/test_log_request_headers.py dynamic_config usage
340 HTTP and gRPC client requests call `update_server_state` automatically before each request.
342 For main dynamic config documentation:
344 @see @ref dynamic_config_testsuite
346 See also other related fixtures:
347 * @ref pytest_userver.plugins.dynamic_config.dynamic_config "config_service_defaults"
348 * @ref pytest_userver.plugins.dynamic_config.dynamic_config "dynamic_config_fallback_patch"
349 * @ref pytest_userver.plugins.dynamic_config.dynamic_config "mock_configs_service"
351 @ingroup userver_testsuite_fixtures
354 initial_values=config_service_defaults,
355 defaults=_dynamic_config_defaults_storage.snapshot,
356 config_cache_components=dynconf_cache_names,
357 cache_invalidation_state=cache_invalidation_state,
358 changelog=dynamic_config_changelog,
361 with dynamic_config_changelog.rollback(config_service_defaults):
363 for path
in reversed(list(search_path(
'config.json'))):
364 values = _dynconf_load_json_cached(path)
365 updates.update(values)
366 for marker
in request.node.iter_markers(
'config'):
367 marker_json = object_substitute(marker.kwargs)
368 updates.update(marker_json)
369 config.set_values_unsafe(updates)
376def pytest_configure(config):
377 config.addinivalue_line(
379 'config: per-test dynamic config values',
381 config.addinivalue_line(
383 'disable_config_check: disable config mark keys check',
390@pytest.fixture(scope='session')
437 config_fallback_path,
438 dynamic_config_fallback_patch,
441 Fixture that returns default values for dynamic config. You may override
442 it in your local conftest.py or fixture:
445 @pytest.fixture(scope='session')
446 def config_service_defaults():
447 with open('defaults.json') as fp:
451 @ingroup userver_testsuite_fixtures
453 if not config_fallback_path:
454 return dynamic_config_fallback_patch
456 if pathlib.Path(config_fallback_path).exists():
457 with open(config_fallback_path,
'r', encoding=
'utf-8')
as file:
458 fallback = json.load(file)
459 fallback.update(dynamic_config_fallback_patch)
463 'Invalid path specified in config_fallback_path fixture. '
464 'Probably invalid path was passed in --config-fallback pytest option.',
468@dataclasses.dataclass(frozen=False)
470 snapshot: typing.Optional[ConfigDict]
472 async def update(self, client, dynamic_config) -> None:
474 defaults = await client.get_dynamic_config_defaults()
475 if not isinstance(defaults, dict):
479 dynamic_config._defaults = defaults
490@pytest.fixture(scope='session')
498 Returns a function that adjusts the static configuration file for
500 Sets `dynamic-config.fs-cache-path` to a file that is reset after the tests
501 to avoid leaking dynamic config values between test sessions.
503 @ingroup userver_testsuite_fixtures
506 def patch_config(config, _config_vars) -> None:
507 components = config[
'components_manager'][
'components']
508 dynamic_config_component = components.get(
'dynamic-config',
None)
or {}
509 if dynamic_config_component.get(
'fs-cache-path',
'') ==
'':
512 cache_path = service_tmpdir /
'configs' /
'config_cache.json'
514 if cache_path.is_file():
518 dynamic_config_component[
'fs-cache-path'] = str(cache_path)
523@pytest.fixture(scope='session')
526 Returns a function that adjusts the static configuration file for
528 Removes `dynamic-config.defaults-path`.
529 Updates `dynamic-config.defaults` with `config_service_defaults`.
531 @ingroup userver_testsuite_fixtures
534 def extract_defaults_dict(component_config, config_vars) -> dict:
535 defaults_field = component_config.get(
'defaults',
None)
or {}
536 if isinstance(defaults_field, dict):
537 return defaults_field
538 elif isinstance(defaults_field, str):
539 if defaults_field.startswith(
'$'):
540 return config_vars.get(defaults_field[1:], {})
541 assert False, f
'Unexpected static config option `dynamic-config.defaults`: {defaults_field!r}'
543 def _patch_config(config_yaml, config_vars):
544 components = config_yaml[
'components_manager'][
'components']
545 if components.get(
'dynamic-config',
None)
is None:
546 components[
'dynamic-config'] = {}
547 dynconf_component = components[
'dynamic-config']
549 dynconf_component.pop(
'defaults-path',
None)
551 extract_defaults_dict(dynconf_component, config_vars),
552 **config_service_defaults,
554 dynconf_component[
'defaults'] = defaults
559@pytest.fixture(scope='session')
598 dynamic_config: DynamicConfig,
599 dynamic_config_changelog: _Changelog,
602 Adds a mockserver handler that forwards dynamic_config to service's
603 `dynamic-config-client` component.
605 @ingroup userver_testsuite_fixtures
608 @mockserver.json_handler('/configs-service/configs/values')
609 def _mock_configs(request):
610 updates = dynamic_config_changelog.get_updated_since(
611 dynamic_config.get_values_unsafe(),
612 request.json.get(
'updated_since',
''),
613 request.json.get(
'ids'),
615 response = {
'configs': updates.values,
'updated_at': updates.timestamp}
617 response[
'removed'] = updates.removed
620 @mockserver.json_handler('/configs-service/configs/status')
621 def _mock_configs_status(_request):
623 'updated_at': dynamic_config_changelog.timestamp.strftime(
624 '%Y-%m-%dT%H:%M:%SZ',