2424 'dynamic-config-client-updater' : '_userver_dynconfig_cache_control' ,
2525}
2626
27- _CONFIG_CACHES = tuple (USERVER_CACHE_CONTROL_HOOKS .keys ())
28-
2927
3028class 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+
3843class 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+
4251ConfigDict = 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 )
410440class _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' )
433467def 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
0 commit comments