111 timestamp: datetime.datetime
112 commited_entries: typing.List[_ChangelogEntry]
113 staged_entry: _ChangelogEntry
117 0, datetime.timezone.utc,
124 def service_timestamp(self) -> str:
127 def next_timestamp(self) -> str:
132 """Commit staged changed if any and return last commited entry."""
141 def get_updated_since(
145 ids: typing.Optional[typing.List[str]] =
None,
150 values = {name: values[name]
for name
in ids
if name
in values}
151 removed = [name
for name
in removed
if name
in ids]
153 timestamp=entry.timestamp, values=values, removed=removed,
156 def _get_updated_since(
157 self, values: ConfigDict, updated_since: str,
158 ) -> typing.Tuple[ConfigDict, typing.List[str]]:
159 if not updated_since:
162 last_known_state = {}
164 if entry.timestamp > updated_since:
165 dirty_keys.update(entry.dirty_keys)
167 if entry.timestamp == updated_since:
168 last_known_state = entry.state
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:
180 return result, removed
182 def add_entries(self, values: ConfigDict):
185 @contextlib.contextmanager
186 def rollback(self, defaults: ConfigDict):
192 def _do_rollback(self, defaults: ConfigDict):
198 maybe_dirty.update(entry.dirty_keys)
201 last_state = last.state
204 for key
in maybe_dirty:
205 original = defaults.get(key, RemoveKey)
206 if last_state[key] != original:
208 reverted[key] = original
211 timestamp=last.timestamp,
213 dirty_keys=dirty_keys,
219 dirty_keys=dirty_keys.copy(),
221 prev_state=entry.state,
226 """Simple dynamic config backend."""
231 initial_values: ConfigDict,
232 defaults: typing.Optional[ConfigDict],
233 config_cache_components: typing.Iterable[str],
235 changelog: _Changelog,
237 self.
_values = initial_values.copy()
245 def set_values(self, values: ConfigDict):
248 def set_values_unsafe(self, values: ConfigDict):
253 def set(self, **values):
256 def get_values_unsafe(self) -> ConfigDict:
259 def get(self, key: str, default: typing.Any =
None) -> typing.Any:
261 return copy.deepcopy(self.
_values[key])
263 return copy.deepcopy(self.
_defaults[key])
264 if default
is not 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`',
275 def remove_values(self, keys):
276 extra_keys = set(keys).difference(self.
_values.keys())
279 f
'Attempting to remove nonexistent configs: {extra_keys}',
283 self.
_changelog.add_entries({key: RemoveKey
for key
in keys})
286 def remove(self, key):
289 @contextlib.contextmanager
290 def modify(self, key: str) -> typing.Any:
291 value = self.
get(key)
295 @contextlib.contextmanager
297 self, *keys: typing.Tuple[str, ...],
298 ) -> typing.Tuple[typing.Any, ...]:
299 values = tuple(self.
get(key)
for key
in keys)
303 def _sync_with_service(self):
314 cache_invalidation_state,
315 _dynamic_config_defaults_storage,
316 config_service_defaults,
317 dynamic_config_changelog,
318 _dynconf_load_json_cached,
322 Fixture that allows to control dynamic config values used by the service.
324 After change to the config, be sure to call:
326 await service_client.update_server_state()
329 HTTP client requests call it automatically before each request.
331 @ingroup userver_testsuite_fixtures
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,
341 with dynamic_config_changelog.rollback(config_service_defaults):
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)
409 config_fallback_path, dynamic_config_fallback_patch,
412 Fixture that returns default values for dynamic config. You may override
413 it in your local conftest.py or fixture:
416 @pytest.fixture(scope='session')
417 def config_service_defaults():
418 with open('defaults.json') as fp:
422 @ingroup userver_testsuite_fixtures
424 if not config_fallback_path:
425 return dynamic_config_fallback_patch
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)
434 'Invalid path specified in config_fallback_path fixture. '
435 'Probably invalid path was passed in --config-fallback pytest option.',
439@dataclasses.dataclass(frozen=False)
441 snapshot: typing.Optional[ConfigDict]
443 async def update(self, client, dynamic_config) -> None:
445 defaults = await client.get_dynamic_config_defaults()
446 if not isinstance(defaults, dict):
450 dynamic_config._defaults = defaults
461@pytest.fixture(scope='package')
469 Returns a function that adjusts the static configuration file for
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.
474 @ingroup userver_testsuite_fixtures
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',
'') ==
'':
483 cache_path = service_tmpdir /
'configs' /
'config_cache.json'
485 if cache_path.is_file():
489 dynamic_config_component[
'fs-cache-path'] = str(cache_path)
494@pytest.fixture(scope='session')
497 Returns a function that adjusts the static configuration file for
499 Removes `dynamic-config.defaults-path`.
500 Updates `dynamic-config.defaults` with `config_service_defaults`.
502 @ingroup userver_testsuite_fixtures
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:], {})
513 f
'Unexpected static config option '
514 f
'`dynamic-config.defaults`: {defaults_field!r}'
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']
523 dynconf_component.pop(
'defaults-path',
None)
525 extract_defaults_dict(dynconf_component, config_vars),
526 **config_service_defaults,
528 dynconf_component[
'defaults'] = defaults
533@pytest.fixture(scope='session')
566 dynamic_config: DynamicConfig,
567 dynamic_config_changelog: _Changelog,
570 Adds a mockserver handler that forwards dynamic_config to service's
571 `dynamic-config-client` component.
573 @ingroup userver_testsuite_fixtures
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'),
583 response = {
'configs': updates.values,
'updated_at': updates.timestamp}
585 response[
'removed'] = updates.removed
588 @mockserver.json_handler('/configs-service/configs/status')
589 def _mock_configs_status(_request):
591 'updated_at': dynamic_config_changelog.timestamp.strftime(
592 '%Y-%m-%dT%H:%M:%SZ',