56 dirty_keys: typing.Set[str]
63 previous: typing.Optional[
'_ChangelogEntry'],
67 state = previous.state.copy()
70 return cls(timestamp=timestamp, dirty_keys=set(), state=state)
73 def has_changes(self) -> bool:
76 def update(self, values: ConfigDict):
77 self.
state.update(values)
81@dataclasses.dataclass(frozen=True)
92 entry: _ChangelogEntry
93 entries: typing.List[_ChangelogEntry]
96 self.
timestamp = datetime.datetime.fromtimestamp(
97 0, datetime.timezone.utc,
104 def service_timestamp(self) -> str:
105 return self.
timestamp.strftime(
'%Y-%m-%dT%H:%M:%SZ')
107 def next_timestamp(self) -> str:
108 self.
timestamp += datetime.timedelta(seconds=1)
117 def get_updated_since(
121 ids: typing.Optional[typing.List[str]] =
None,
127 values = {name: values[name]
for name
in ids
if name
in values}
128 removed = [name
for name
in removed
if name
in ids]
130 timestamp=entry.timestamp, values=values, removed=removed,
133 def _get_updated_since(
134 self, values: ConfigDict, updated_since: str,
135 ) -> typing.Tuple[ConfigDict, typing.List[str]]:
136 if not updated_since:
139 last_known_state = {}
141 if entry.timestamp > updated_since:
142 dirty_keys.update(entry.dirty_keys)
144 last_known_state = entry.state
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:
156 return result, removed
158 def add_entries(self, values: ConfigDict):
161 @contextlib.contextmanager
162 def rollback(self, defaults: ConfigDict):
169 def _do_rollback(self, first_index: int, defaults: ConfigDict):
176 changes = {key: defaults.get(key, RemoveKey)
for key
in dirty_keys}
182 """Simple dynamic config backend."""
187 initial_values: ConfigDict,
188 config_cache_components: typing.Iterable[str],
190 changelog: _Changelog,
192 self.
_values = initial_values.copy()
197 def set_values(self, values: ConfigDict):
200 def set_values_unsafe(self, values: ConfigDict):
205 def set(self, **values):
208 def get_values_unsafe(self) -> ConfigDict:
211 def get(self, key: str, default: typing.Any =
None) -> typing.Any:
213 if default
is not None:
216 return copy.deepcopy(self.
_values[key])
218 def remove_values(self, keys):
219 extra_keys = set(keys).difference(self.
_values.keys())
222 f
'Attempting to remove nonexistent configs: {extra_keys}',
226 self.
_changelog.add_entries({key: RemoveKey
for key
in keys})
229 def remove(self, key):
232 @contextlib.contextmanager
233 def modify(self, key: str) -> typing.Any:
234 value = self.
get(key)
238 @contextlib.contextmanager
240 self, *keys: typing.Tuple[str, ...],
241 ) -> typing.Tuple[typing.Any, ...]:
242 values = tuple(self.
get(key)
for key
in keys)
246 def _sync_with_service(self):
257 cache_invalidation_state,
258 _config_service_defaults_updated,
259 dynamic_config_changelog,
260 _dynconfig_load_json_cached,
264 Fixture that allows to control dynamic config values used by the service.
266 After change to the config, be sure to call:
268 await service_client.update_server_state()
271 HTTP client requests call it automatically before each request.
273 @ingroup userver_testsuite_fixtures
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,
282 with dynamic_config_changelog.rollback(
283 _config_service_defaults_updated.snapshot,
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)
295@pytest.fixture(scope='session')
342 config_fallback_path, dynamic_config_fallback_patch,
345 Fixture that returns default values for dynamic config. You may override
346 it in your local conftest.py or fixture:
349 @pytest.fixture(scope='session')
350 def config_service_defaults():
351 with open('defaults.json') as fp:
355 @ingroup userver_testsuite_fixtures
357 if not config_fallback_path:
358 return dynamic_config_fallback_patch
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)
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.',
374@dataclasses.dataclass(frozen=False)
378 async def update(self, client, dynamic_config) -> None:
380 values = await client.get_dynamic_config_defaults()
381 if not isinstance(values, dict):
384 values.update(dynamic_config.get_values_unsafe())
392@pytest.fixture(scope='package')
419 Returns a function that adjusts the static configuration file for
421 Sets `dynamic-config.defaults-path` according to `config_service_defaults`.
422 Updates `dynamic-config.defaults` with `config_service_defaults`.
424 @ingroup userver_testsuite_fixtures
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:], {})
435 f
'Unexpected static config option '
436 f
'`dynamic-config.defaults`: {defaults_field!r}'
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']
445 dynconf_component.pop(
'defaults-path',
None)
447 extract_defaults_dict(dynconf_component, config_vars),
448 **config_service_defaults,
450 dynconf_component[
'defaults'] = defaults
455@pytest.fixture(scope='session')
488 dynamic_config: DynamicConfig,
489 dynamic_config_changelog: _Changelog,
492 Adds a mockserver handler that forwards dynamic_config to service's
493 `dynamic-config-client` component.
495 @ingroup userver_testsuite_fixtures
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'),
505 response = {
'configs': updates.values,
'updated_at': updates.timestamp}
507 response[
'removed'] = updates.removed
510 @mockserver.json_handler('/configs-service/configs/status')
511 def _mock_configs_status(_request):
512 return {
'updated_at': dynamic_config_changelog.timestamp}