65 dirty_keys: typing.Set[str]
67 prev_state: ConfigDict
71 cls, *, previous: typing.Optional[
'_ChangelogEntry'], timestamp: str,
74 prev_state = previous.state
80 state=prev_state.copy(),
81 prev_state=prev_state,
85 def has_changes(self) -> bool:
88 def update(self, values: ConfigDict):
89 for key, value
in values.items():
94 self.
state.update(values)
97@dataclasses.dataclass(frozen=True)
108 timestamp: datetime.datetime
109 commited_entries: typing.List[_ChangelogEntry]
110 staged_entry: _ChangelogEntry
114 0, datetime.timezone.utc,
121 def service_timestamp(self) -> str:
124 def next_timestamp(self) -> str:
129 """Commit staged changed if any and return last commited entry."""
138 def get_updated_since(
142 ids: typing.Optional[typing.List[str]] =
None,
147 values = {name: values[name]
for name
in ids
if name
in values}
148 removed = [name
for name
in removed
if name
in ids]
150 timestamp=entry.timestamp, values=values, removed=removed,
153 def _get_updated_since(
154 self, values: ConfigDict, updated_since: str,
155 ) -> typing.Tuple[ConfigDict, typing.List[str]]:
156 if not updated_since:
159 last_known_state = {}
161 if entry.timestamp > updated_since:
162 dirty_keys.update(entry.dirty_keys)
164 if entry.timestamp == updated_since:
165 last_known_state = entry.state
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:
177 return result, removed
179 def add_entries(self, values: ConfigDict):
182 @contextlib.contextmanager
183 def rollback(self, defaults: ConfigDict):
189 def _do_rollback(self, defaults: ConfigDict):
195 maybe_dirty.update(entry.dirty_keys)
198 last_state = last.state
201 for key
in maybe_dirty:
202 original = defaults.get(key, RemoveKey)
203 if last_state[key] != original:
205 reverted[key] = original
208 timestamp=last.timestamp,
210 dirty_keys=dirty_keys,
216 dirty_keys=dirty_keys.copy(),
218 prev_state=entry.state,
223 """Simple dynamic config backend."""
228 initial_values: ConfigDict,
229 defaults: typing.Optional[ConfigDict],
230 config_cache_components: typing.Iterable[str],
232 changelog: _Changelog,
234 self.
_values = initial_values.copy()
242 def set_values(self, values: ConfigDict):
245 def set_values_unsafe(self, values: ConfigDict):
250 def set(self, **values):
253 def get_values_unsafe(self) -> ConfigDict:
256 def get(self, key: str, default: typing.Any =
None) -> typing.Any:
258 return copy.deepcopy(self.
_values[key])
260 return copy.deepcopy(self.
_defaults[key])
261 if default
is not 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`',
272 def remove_values(self, keys):
273 extra_keys = set(keys).difference(self.
_values.keys())
276 f
'Attempting to remove nonexistent configs: {extra_keys}',
280 self.
_changelog.add_entries({key: RemoveKey
for key
in keys})
283 def remove(self, key):
286 @contextlib.contextmanager
287 def modify(self, key: str) -> typing.Any:
288 value = self.
get(key)
292 @contextlib.contextmanager
294 self, *keys: typing.Tuple[str, ...],
295 ) -> typing.Tuple[typing.Any, ...]:
296 values = tuple(self.
get(key)
for key
in keys)
300 def _sync_with_service(self):
311 cache_invalidation_state,
312 _dynamic_config_defaults_storage,
313 config_service_defaults,
314 dynamic_config_changelog,
315 _dynconf_load_json_cached,
319 Fixture that allows to control dynamic config values used by the service.
321 After change to the config, be sure to call:
323 await service_client.update_server_state()
326 HTTP client requests call it automatically before each request.
328 @ingroup userver_testsuite_fixtures
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,
338 with dynamic_config_changelog.rollback(config_service_defaults):
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)
406 config_fallback_path, dynamic_config_fallback_patch,
409 Fixture that returns default values for dynamic config. You may override
410 it in your local conftest.py or fixture:
413 @pytest.fixture(scope='session')
414 def config_service_defaults():
415 with open('defaults.json') as fp:
419 @ingroup userver_testsuite_fixtures
421 if not config_fallback_path:
422 return dynamic_config_fallback_patch
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)
431 'Invalid path specified in config_fallback_path fixture. '
432 'Probably invalid path was passed in --config-fallback pytest option.',
436@dataclasses.dataclass(frozen=False)
438 snapshot: typing.Optional[ConfigDict]
440 async def update(self, client, dynamic_config) -> None:
442 defaults = await client.get_dynamic_config_defaults()
443 if not isinstance(defaults, dict):
447 dynamic_config._defaults = defaults
458@pytest.fixture(scope='package')
466 Returns a function that adjusts the static configuration file for
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.
471 @ingroup userver_testsuite_fixtures
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',
'') ==
'':
480 cache_path = service_tmpdir /
'configs' /
'config_cache.json'
482 if cache_path.is_file():
486 dynamic_config_component[
'fs-cache-path'] = str(cache_path)
491@pytest.fixture(scope='session')
494 Returns a function that adjusts the static configuration file for
496 Removes `dynamic-config.defaults-path`.
497 Updates `dynamic-config.defaults` with `config_service_defaults`.
499 @ingroup userver_testsuite_fixtures
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:], {})
510 f
'Unexpected static config option '
511 f
'`dynamic-config.defaults`: {defaults_field!r}'
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']
520 dynconf_component.pop(
'defaults-path',
None)
522 extract_defaults_dict(dynconf_component, config_vars),
523 **config_service_defaults,
525 dynconf_component[
'defaults'] = defaults
530@pytest.fixture(scope='session')
563 dynamic_config: DynamicConfig,
564 dynamic_config_changelog: _Changelog,
567 Adds a mockserver handler that forwards dynamic_config to service's
568 `dynamic-config-client` component.
570 @ingroup userver_testsuite_fixtures
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'),
580 response = {
'configs': updates.values,
'updated_at': updates.timestamp}
582 response[
'removed'] = updates.removed
585 @mockserver.json_handler('/configs-service/configs/status')
586 def _mock_configs_status(_request):
588 'updated_at': dynamic_config_changelog.timestamp.strftime(
589 '%Y-%m-%dT%H:%M:%SZ',