22
33import fnmatch
44from copy import deepcopy
5- from typing import Any
5+ from typing import Any , Protocol , runtime_checkable
66
77from attrs import define , field
88from loguru import logger
@@ -23,6 +23,15 @@ def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
2323 return result
2424
2525
26+ @runtime_checkable
27+ class ConfigurationChangeListener (Protocol ):
28+ """
29+ Protocol for configuration change listeners.
30+ """
31+
32+ def __call__ (self , config_map : ConfigurationMap , ** kwargs : Any ) -> Any : ...
33+
34+
2635@define
2736class ConfigurationMap :
2837 """
@@ -32,21 +41,49 @@ class ConfigurationMap:
3241
3342 _global_config : dict [str , Any ] = field (factory = dict )
3443 _scoped_configs : list [tuple [str , dict [str , Any ]]] = field (factory = list )
44+ _on_change_callbacks : list [ConfigurationChangeListener ] = field (
45+ factory = list , init = False
46+ )
47+
48+ def on_change (self , callback : ConfigurationChangeListener ) -> None :
49+ """
50+ Register a callback to be called when the configuration changes.
51+ """
52+ self ._on_change_callbacks .append (callback )
53+
54+ def _notify_change (self , ** kwargs : Any ) -> None :
55+ for callback in self ._on_change_callbacks :
56+ try :
57+ callback (self , ** kwargs )
58+ except Exception as e :
59+ logger .error (f"Error in configuration change callback: { e } " )
60+
61+ def update_global (
62+ self , config : dict [str , Any ], merge : bool = True , ** kwargs : Any
63+ ) -> None :
64+ """
65+ Update global configuration.
66+ """
67+ if merge :
68+ self ._global_config = deep_merge (self ._global_config , config )
69+ else :
70+ self ._global_config = deepcopy (config )
71+ self ._notify_change (** kwargs )
3572
36- def add_scope (self , pattern : str , config : dict [str , Any ]) -> None :
73+ def add_scope (self , pattern : str , config : dict [str , Any ], ** kwargs : Any ) -> None :
3774 """
3875 Add a configuration override for a specific file pattern.
3976
4077 :param pattern: Glob pattern (e.g. "**/tests/**", "*.py")
4178 :param config: The configuration dict to merge for this scope
4279 """
4380 self ._scoped_configs .append ((pattern , config ))
81+ self ._notify_change (** kwargs )
4482
4583 def _get_section (self , config : Any , section : str | None ) -> Any :
4684 if not section :
4785 return config
4886
49- # Traverse the config dictionary using the section path (e.g. "python.analysis")
5087 current = config
5188 for part in section .split ("." ):
5289 if isinstance (current , dict ) and part in current :
@@ -56,10 +93,8 @@ def _get_section(self, config: Any, section: str | None) -> Any:
5693 return current
5794
5895 def get (self , scope_uri : str | None , section : str | None ) -> Any :
59- # Start with global config
6096 final_config = self ._global_config
6197
62- # If we have a scope, merge matching scoped configs
6398 if scope_uri :
6499 try :
65100 path_str = str (from_local_uri (scope_uri ))
0 commit comments