Skip to content

Commit 155421c

Browse files
committed
feat testsuite: don't require dynamic_config_fallback.json
3797c7655757d8aff45fb64b95a9dd25852bb814
1 parent 2dafbd7 commit 155421c

File tree

5 files changed

+141
-49
lines changed

5 files changed

+141
-49
lines changed

core/functional_tests/basic_chaos/tests-nonchaos/dynamic_configs/test_changelog.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def __init__(self, *, cache_invalidation_state, defaults):
1414
def new_config(self):
1515
config = dynamic_config.DynamicConfig(
1616
initial_values=self.defaults,
17+
defaults=None,
1718
config_cache_components=[],
1819
cache_invalidation_state=self.cache_invalidation_state,
1920
changelog=self.changelog,

core/src/dynamic_config/storage/component.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ void DynamicConfig::Impl::ReadFallback(const ComponentConfig& config) {
261261
try {
262262
const auto fallback_contents =
263263
fs::ReadFileContents(*fs_task_processor_, *default_overrides_path);
264-
fallback_config_.Parse(fallback_contents, false);
264+
fallback_config_.Parse(fallback_contents, true);
265265
} catch (const std::exception& ex) {
266266
throw std::runtime_error(
267267
fmt::format("Failed to load dynamic config fallback from '{}': {}",

testsuite/pytest_plugins/pytest_userver/plugins/config.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,6 @@ class _UserverConfig(typing.NamedTuple):
6868

6969
def pytest_configure(config):
7070
config.pluginmanager.register(_UserverConfigPlugin(), 'userver_config')
71-
config.addinivalue_line(
72-
'markers', 'config: per-test dynamic config values',
73-
)
7471

7572

7673
def pytest_addoption(parser) -> None:

testsuite/pytest_plugins/pytest_userver/plugins/dynamic_config.py

Lines changed: 119 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@
2424
'dynamic-config-client-updater': '_userver_dynconfig_cache_control',
2525
}
2626

27-
_CONFIG_CACHES = tuple(USERVER_CACHE_CONTROL_HOOKS.keys())
28-
2927

3028
class BaseError(Exception):
3129
"""Base class for exceptions from this module"""
@@ -35,10 +33,21 @@ class DynamicConfigNotFoundError(BaseError):
3533
"""Config parameter was not found and no default was provided"""
3634

3735

36+
class DynamicConfigUninitialized(BaseError):
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+
3843
class InvalidDefaultsError(BaseError):
3944
"""Dynamic config defaults action returned invalid response"""
4045

4146

47+
class UnknownConfigError(BaseError):
48+
"""Invalid dynamic config name in @pytest.mark.config"""
49+
50+
4251
ConfigDict = typing.Dict[str, typing.Any]
4352

4453

@@ -220,11 +229,15 @@ def __init__(
220229
self,
221230
*,
222231
initial_values: ConfigDict,
232+
defaults: typing.Optional[ConfigDict],
223233
config_cache_components: typing.Iterable[str],
224234
cache_invalidation_state: caches.InvalidationState,
225235
changelog: _Changelog,
226236
):
227237
self._values = initial_values.copy()
238+
# Defaults are only there for convenience, to allow accessing them
239+
# in tests using dynamic_config.get. They are not sent to the service.
240+
self._defaults = defaults
228241
self._cache_invalidation_state = cache_invalidation_state
229242
self._config_cache_components = config_cache_components
230243
self._changelog = changelog
@@ -244,11 +257,20 @@ def get_values_unsafe(self) -> ConfigDict:
244257
return self._values
245258

246259
def get(self, key: str, default: typing.Any = None) -> typing.Any:
247-
if key not in self._values:
248-
if default is not None:
249-
return default
250-
raise DynamicConfigNotFoundError(f'Config {key!r} is not found')
251-
return copy.deepcopy(self._values[key])
260+
if key in self._values:
261+
return copy.deepcopy(self._values[key])
262+
if self._defaults is not None and key in self._defaults:
263+
return copy.deepcopy(self._defaults[key])
264+
if default is not None:
265+
return default
266+
if self._defaults is None:
267+
raise DynamicConfigUninitialized(
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`',
272+
)
273+
raise DynamicConfigNotFoundError(f'Config {key!r} is not found')
252274

253275
def remove_values(self, keys):
254276
extra_keys = set(keys).difference(self._values.keys())
@@ -290,9 +312,10 @@ def dynamic_config(
290312
search_path,
291313
object_substitute,
292314
cache_invalidation_state,
293-
_config_service_defaults_updated,
315+
_dynamic_config_defaults_storage,
316+
config_service_defaults,
294317
dynamic_config_changelog,
295-
_dynconfig_load_json_cached,
318+
_dynconf_load_json_cached,
296319
dynconf_cache_names,
297320
) -> DynamicConfig:
298321
"""
@@ -308,17 +331,17 @@ def dynamic_config(
308331
@ingroup userver_testsuite_fixtures
309332
"""
310333
config = DynamicConfig(
311-
initial_values=_config_service_defaults_updated.snapshot,
334+
initial_values=config_service_defaults,
335+
defaults=_dynamic_config_defaults_storage.snapshot,
312336
config_cache_components=dynconf_cache_names,
313337
cache_invalidation_state=cache_invalidation_state,
314338
changelog=dynamic_config_changelog,
315339
)
316-
updates = {}
317-
with dynamic_config_changelog.rollback(
318-
_config_service_defaults_updated.snapshot,
319-
):
340+
341+
with dynamic_config_changelog.rollback(config_service_defaults):
342+
updates = {}
320343
for path in reversed(list(search_path('config.json'))):
321-
values = _dynconfig_load_json_cached(path)
344+
values = _dynconf_load_json_cached(path)
322345
updates.update(values)
323346
for marker in request.node.iter_markers('config'):
324347
marker_json = object_substitute(marker.kwargs)
@@ -327,22 +350,31 @@ def dynamic_config(
327350
yield config
328351

329352

353+
def pytest_configure(config):
354+
config.addinivalue_line(
355+
'markers', 'config: per-test dynamic config values',
356+
)
357+
config.addinivalue_line(
358+
'markers', 'disable_config_check: disable config mark keys check',
359+
)
360+
361+
330362
@pytest.fixture(scope='session')
331-
def dynconf_cache_names():
332-
return tuple(_CONFIG_CACHES)
363+
def dynconf_cache_names() -> typing.Iterable[str]:
364+
return tuple(USERVER_CACHE_CONTROL_HOOKS.keys())
333365

334366

335367
@pytest.fixture(scope='session')
336-
def _dynconfig_json_cache():
368+
def _dynconf_json_cache():
337369
return {}
338370

339371

340372
@pytest.fixture
341-
def _dynconfig_load_json_cached(json_loads, _dynconfig_json_cache):
373+
def _dynconf_load_json_cached(json_loads, _dynconf_json_cache):
342374
def load(path: pathlib.Path):
343-
if path not in _dynconfig_json_cache:
344-
_dynconfig_json_cache[path] = json_loads(path.read_text())
345-
return _dynconfig_json_cache[path]
375+
if path not in _dynconf_json_cache:
376+
_dynconf_json_cache[path] = json_loads(path.read_text())
377+
return _dynconf_json_cache[path]
346378

347379
return load
348380

@@ -399,38 +431,49 @@ def config_service_defaults():
399431
return fallback
400432

401433
raise RuntimeError(
402-
'Either provide the path to dynamic config defaults file using '
403-
'--config-fallback pytest option, or override '
404-
f'{config_service_defaults.__name__} fixture to provide custom '
405-
'dynamic config loading behavior.',
434+
'Invalid path specified in config_fallback_path fixture. '
435+
'Probably invalid path was passed in --config-fallback pytest option.',
406436
)
407437

408438

409439
@dataclasses.dataclass(frozen=False)
410440
class _ConfigDefaults:
411-
snapshot: ConfigDict
441+
snapshot: typing.Optional[ConfigDict]
412442

413443
async def update(self, client, dynamic_config) -> None:
414-
if not self.snapshot:
415-
values = await client.get_dynamic_config_defaults()
416-
if not isinstance(values, dict):
444+
if self.snapshot is None:
445+
defaults = await client.get_dynamic_config_defaults()
446+
if not isinstance(defaults, dict):
417447
raise InvalidDefaultsError()
418-
# There may already be some config overrides from the current test.
419-
values.update(dynamic_config.get_values_unsafe())
420-
self.snapshot = values
421-
dynamic_config.set_values(self.snapshot)
448+
self.snapshot = defaults
449+
# pylint:disable=protected-access
450+
dynamic_config._defaults = defaults
422451

423452

424-
# If there is no config_fallback_path, then we want to ask the service
453+
# config_service_defaults fetches the dynamic config overrides, e.g. specified
454+
# in the json file, then userver_config_dynconf_fallback forwards them
455+
# to the service so that it has the correct dynamic config defaults.
456+
#
457+
# Independently of that, it is useful to have values for all configs, even
458+
# unspecified in tests, on the testsuite side. For that, we ask the service
425459
# for the dynamic config defaults after it's launched. It's enough to update
426460
# defaults once per service launch.
427461
@pytest.fixture(scope='package')
428-
def _config_service_defaults_updated(config_service_defaults):
429-
return _ConfigDefaults(snapshot=config_service_defaults)
462+
def _dynamic_config_defaults_storage() -> _ConfigDefaults:
463+
return _ConfigDefaults(snapshot=None)
430464

431465

432466
@pytest.fixture(scope='session')
433467
def userver_config_dynconf_cache(service_tmpdir):
468+
"""
469+
Returns a function that adjusts the static configuration file for
470+
the testsuite.
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.
473+
474+
@ingroup userver_testsuite_fixtures
475+
"""
476+
434477
def patch_config(config, _config_vars) -> None:
435478
components = config['components_manager']['components']
436479
dynamic_config_component = components.get('dynamic-config', None) or {}
@@ -453,7 +496,7 @@ def userver_config_dynconf_fallback(config_service_defaults):
453496
"""
454497
Returns a function that adjusts the static configuration file for
455498
the testsuite.
456-
Sets `dynamic-config.defaults-path` according to `config_service_defaults`.
499+
Removes `dynamic-config.defaults-path`.
457500
Updates `dynamic-config.defaults` with `config_service_defaults`.
458501
459502
@ingroup userver_testsuite_fixtures
@@ -562,3 +605,41 @@ def cache_control(updater, timestamp):
562605
return entry.timestamp
563606

564607
return cache_control
608+
609+
610+
_CHECK_CONFIG_ERROR = (
611+
'Your are trying to override config value using '
612+
'@pytest.mark.config({}) '
613+
'that does not seem to be used by your service.\n\n'
614+
'In case you really need to disable this check please add the '
615+
'following mark to your testcase:\n\n'
616+
'@pytest.mark.disable_config_check'
617+
)
618+
619+
620+
# Should be invoked after _dynamic_config_defaults_storage is filled.
621+
@pytest.fixture
622+
def _check_config_marks(
623+
request, _dynamic_config_defaults_storage,
624+
) -> typing.Callable[[], None]:
625+
def check():
626+
config_defaults = _dynamic_config_defaults_storage.snapshot
627+
assert config_defaults is not None
628+
629+
if request.node.get_closest_marker('disable_config_check'):
630+
return
631+
632+
unknown_configs = [
633+
key
634+
for marker in request.node.iter_markers('config')
635+
for key in marker.kwargs
636+
if key not in config_defaults
637+
]
638+
639+
if unknown_configs:
640+
message = _CHECK_CONFIG_ERROR.format(
641+
', '.join(f'{key}=...' for key in sorted(unknown_configs)),
642+
)
643+
raise UnknownConfigError(message)
644+
645+
return check

testsuite/pytest_plugins/pytest_userver/plugins/service_client.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ async def service_client(
8383
mock_configs_service,
8484
cleanup_userver_dumps,
8585
userver_client_cleanup,
86-
_config_service_defaults_updated,
8786
_testsuite_client_config: client.TestsuiteClientConfig,
8887
_service_client_base,
8988
_service_client_testsuite,
@@ -107,16 +106,27 @@ async def service_client(
107106
yield _service_client_base
108107
else:
109108
service_client = _service_client_testsuite(daemon)
110-
await _config_service_defaults_updated.update(
111-
service_client, dynamic_config,
112-
)
113-
114109
async with userver_client_cleanup(service_client):
115110
yield service_client
116111

117112

118113
@pytest.fixture
119-
def userver_client_cleanup(request, _userver_logging_plugin):
114+
def userver_client_cleanup(
115+
request,
116+
_userver_logging_plugin,
117+
_dynamic_config_defaults_storage,
118+
_check_config_marks,
119+
dynamic_config,
120+
) -> typing.Callable[[client.Client], typing.AsyncGenerator]:
121+
"""
122+
Contains the pre-test and post-test setup that depends
123+
on @ref service_client.
124+
125+
Feel free to override, but in that case make sure to call the original
126+
`userver_client_cleanup` fixture instance.
127+
128+
@ingroup userver_testsuite_fixtures
129+
"""
120130
marker = request.node.get_closest_marker('suspend_periodic_tasks')
121131
if marker:
122132
tasks_to_suspend = marker.args
@@ -138,9 +148,12 @@ async def do_flush():
138148
# Service is already started we don't want startup logs to be shown
139149
_userver_logging_plugin.update_position()
140150

151+
await _dynamic_config_defaults_storage.update(client, dynamic_config)
152+
_check_config_marks()
153+
141154
await client.suspend_periodic_tasks(tasks_to_suspend)
142155
try:
143-
yield client
156+
yield
144157
finally:
145158
await client.resume_all_periodic_tasks()
146159

0 commit comments

Comments
 (0)