99import json
1010import logging
1111import os
12+ import fcntl
13+ import copy
1214from pathlib import Path
15+ from threading import RLock
1316from typing import Any , Dict , List , Optional , Tuple
1417
1518logger = logging .getLogger (__name__ )
@@ -19,12 +22,14 @@ class ConfigurationManager:
1922 """Manages PiTrac configuration with JSON-based system"""
2023
2124 def __init__ (self ):
25+ self ._lock = RLock ()
26+
2227 self ._raw_metadata = self ._load_raw_metadata ()
2328 sys_paths = self ._raw_metadata .get ("systemPaths" , {})
24-
29+
2530 def expand_path (path_str : str ) -> Path :
2631 return Path (path_str .replace ("~" , str (Path .home ())))
27-
32+
2833 # Configuration paths for three-tier system
2934 self .user_settings_path = expand_path (sys_paths .get ("userSettingsPath" , {}).get ("default" , "~/.pitrac/config/user_settings.json" ))
3035 self .calibration_data_path = expand_path ("~/.pitrac/config/calibration_data.json" )
@@ -62,12 +67,19 @@ def _load_restart_required_params(self) -> set:
6267
6368 def reload (self ) -> None :
6469 """Reload configuration from metadata, calibration data, and user settings"""
65- self .user_settings = self ._load_json (self .user_settings_path )
66- self .calibration_data = self ._load_json (self .calibration_data_path )
67- # Build merged config from metadata defaults + calibration + user overrides
70+ with self ._lock :
71+ self .user_settings = self ._load_json (self .user_settings_path )
72+ self .calibration_data = self ._load_json (self .calibration_data_path )
73+ # Build merged config from metadata defaults + calibration + user overrides
74+ self .merged_config = self ._build_config_from_metadata ()
75+ self .restart_required_params = self ._load_restart_required_params ()
76+ logger .info (f"Loaded configuration: { len (self .calibration_data )} calibration fields, { len (self .user_settings )} user overrides" )
77+
78+ def _rebuild_merged_config (self ) -> None :
79+ """Rebuild merged config from current state (internal use, assumes lock is held)"""
80+ # This method is called from within locked methods, so no lock needed here
6881 self .merged_config = self ._build_config_from_metadata ()
6982 self .restart_required_params = self ._load_restart_required_params ()
70- logger .info (f"Loaded configuration: { len (self .calibration_data )} calibration fields, { len (self .user_settings )} user overrides" )
7183
7284 def _load_json (self , path : Path ) -> Dict [str , Any ]:
7385 """Load JSON file safely"""
@@ -82,13 +94,21 @@ def _load_json(self, path: Path) -> Dict[str, Any]:
8294 return {}
8395
8496 def _save_json (self , path : Path , data : Dict [str , Any ]) -> bool :
85- """Save JSON file with proper formatting"""
97+ """Save JSON file with proper formatting and file locking """
8698 try :
8799 path .parent .mkdir (parents = True , exist_ok = True )
88100
101+ data_copy = copy .deepcopy (data )
102+
89103 temp_path = path .with_suffix (".tmp" )
90104 with open (temp_path , "w" ) as f :
91- json .dump (data , f , indent = 2 , sort_keys = True )
105+ fcntl .flock (f .fileno (), fcntl .LOCK_EX )
106+ try :
107+ json .dump (data_copy , f , indent = 2 , sort_keys = True )
108+ f .flush ()
109+ os .fsync (f .fileno ())
110+ finally :
111+ fcntl .flock (f .fileno (), fcntl .LOCK_UN )
92112
93113 temp_path .replace (path )
94114 logger .info (f"Saved configuration to { path } " )
@@ -150,21 +170,23 @@ def get_config(self, key: Optional[str] = None) -> Any:
150170 Returns:
151171 Configuration value or None if not found
152172 """
153- if key is None :
154- return self .get_merged_with_metadata_defaults ()
155-
156- value = self .get_merged_with_metadata_defaults ()
157- for part in key .split ("." ):
158- if isinstance (value , dict ) and part in value :
159- value = value [part ]
160- else :
161- return None
173+ with self ._lock :
174+ if key is None :
175+ return self .get_merged_with_metadata_defaults ()
176+
177+ value = self .get_merged_with_metadata_defaults ()
178+ for part in key .split ("." ):
179+ if isinstance (value , dict ) and part in value :
180+ value = value [part ]
181+ else :
182+ return None
162183
163- return value
184+ return value
164185
165186 def get_merged_with_metadata_defaults (self ) -> Dict [str , Any ]:
166187 """Get merged config (already includes metadata defaults)"""
167- return self .merged_config .copy ()
188+ with self ._lock :
189+ return copy .deepcopy (self .merged_config )
168190
169191 def get_default (self , key : Optional [str ] = None ) -> Any :
170192 """Get default value from metadata"""
@@ -203,7 +225,8 @@ def get_all_defaults_with_metadata(self) -> Dict[str, Any]:
203225
204226 def get_user_settings (self ) -> Dict [str , Any ]:
205227 """Get only user overrides"""
206- return self .user_settings .copy ()
228+ with self ._lock :
229+ return copy .deepcopy (self .user_settings )
207230
208231 def set_config (self , key : str , value : Any ) -> Tuple [bool , str , bool ]:
209232 """Set configuration value
@@ -215,51 +238,55 @@ def set_config(self, key: str, value: Any) -> Tuple[bool, str, bool]:
215238 Returns:
216239 Tuple of (success, message, requires_restart)
217240 """
218- default_value = self .get_default (key )
219- is_calibration = self ._is_calibration_field (key )
241+ with self ._lock :
242+ default_value = self .get_default (key )
243+ is_calibration = self ._is_calibration_field (key )
244+
245+ if value == default_value :
246+ if is_calibration :
247+ calibration_copy = copy .deepcopy (self .calibration_data )
248+ if self ._delete_from_dict (calibration_copy , key ):
249+ if self ._save_json (self .calibration_data_path , calibration_copy ):
250+ self .calibration_data = calibration_copy
251+ self ._rebuild_merged_config ()
252+ return (
253+ True ,
254+ f"Reset calibration { key } to default value" ,
255+ key in self .restart_required_params ,
256+ )
257+ else :
258+ settings_copy = copy .deepcopy (self .user_settings )
259+ if self ._delete_from_dict (settings_copy , key ):
260+ if self ._save_json (self .user_settings_path , settings_copy ):
261+ self .user_settings = settings_copy
262+ self ._rebuild_merged_config ()
263+ return (
264+ True ,
265+ f"Reset { key } to default value" ,
266+ key in self .restart_required_params ,
267+ )
268+ return True , "Value already at default" , False
220269
221- # If resetting to default
222- if value == default_value :
223- # Remove from appropriate storage
224270 if is_calibration :
225- if self . _delete_from_dict (self .calibration_data , key ):
226- self ._save_json ( self . calibration_data_path , self . calibration_data )
227- self .reload ()
228- return (
229- True ,
230- f"Reset calibration { key } to default value" ,
231- key in self . restart_required_params ,
232- )
271+ calibration_copy = copy . deepcopy (self .calibration_data )
272+ if self ._set_in_dict ( calibration_copy , key , value ):
273+ if self ._save_json ( self . calibration_data_path , calibration_copy ):
274+ self . calibration_data = calibration_copy
275+ self . _rebuild_merged_config ()
276+ requires_restart = key in self . restart_required_params
277+ return True , f"Set calibration { key } = { value } " , requires_restart
278+ return False , "Failed to save calibration data" , False
233279 else :
234- if self ._delete_from_dict (self .user_settings , key ):
235- self ._save_json (self .user_settings_path , self .user_settings )
236- self .reload ()
237- return (
238- True ,
239- f"Reset { key } to default value" ,
240- key in self .restart_required_params ,
241- )
242- return True , "Value already at default" , False
243-
244- # Setting a new value
245- if is_calibration :
246- # Save to calibration data
247- if self ._set_in_dict (self .calibration_data , key , value ):
248- if self ._save_json (self .calibration_data_path , self .calibration_data ):
249- self .reload ()
250- requires_restart = key in self .restart_required_params
251- return True , f"Set calibration { key } = { value } " , requires_restart
252- return False , "Failed to save calibration data" , False
253- else :
254- # Save to user settings
255- if self ._set_in_dict (self .user_settings , key , value ):
256- if self ._save_json (self .user_settings_path , self .user_settings ):
257- self .reload ()
258- requires_restart = key in self .restart_required_params
259- return True , f"Set { key } = { value } " , requires_restart
260- return False , "Failed to save configuration" , False
280+ settings_copy = copy .deepcopy (self .user_settings )
281+ if self ._set_in_dict (settings_copy , key , value ):
282+ if self ._save_json (self .user_settings_path , settings_copy ):
283+ self .user_settings = settings_copy
284+ self ._rebuild_merged_config ()
285+ requires_restart = key in self .restart_required_params
286+ return True , f"Set { key } = { value } " , requires_restart
287+ return False , "Failed to save configuration" , False
261288
262- return False , "Failed to set value" , False
289+ return False , "Failed to set value" , False
263290
264291 def _set_in_dict (self , d : Dict [str , Any ], key : str , value : Any ) -> bool :
265292 """Set value in nested dictionary using dot notation"""
@@ -295,13 +322,23 @@ def _delete_from_dict(self, d: Dict[str, Any], key: str) -> bool:
295322
296323 return False
297324
298- def _cleanup_empty_dicts (self , d : Dict [str , Any ]) -> None :
299- """Remove empty nested dictionaries"""
325+ def _cleanup_empty_dicts (self , d : Dict [str , Any ], max_depth : int = 100 , current_depth : int = 0 ) -> None :
326+ """Remove empty nested dictionaries
327+
328+ Args:
329+ d: Dictionary to clean up
330+ max_depth: Maximum recursion depth (default 100)
331+ current_depth: Current recursion depth
332+ """
333+ if current_depth >= max_depth :
334+ logger .warning (f"Maximum recursion depth { max_depth } reached in _cleanup_empty_dicts" )
335+ return
336+
300337 keys_to_delete = []
301338
302339 for key , value in d .items ():
303340 if isinstance (value , dict ):
304- self ._cleanup_empty_dicts (value )
341+ self ._cleanup_empty_dicts (value , max_depth , current_depth + 1 )
305342 if not value : # Empty dict
306343 keys_to_delete .append (key )
307344
@@ -353,10 +390,10 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]:
353390 Returns:
354391 Tuple of (is_valid, error_message)
355392 """
356- # Use load_configurations_metadata() to get metadata with dynamic options
357- metadata = self .load_configurations_metadata ()
358- settings_metadata = metadata .get ("settings" , {})
359- validation_rules = metadata .get ("validationRules" , {})
393+ with self . _lock :
394+ metadata = self .load_configurations_metadata ()
395+ settings_metadata = metadata .get ("settings" , {})
396+ validation_rules = metadata .get ("validationRules" , {})
360397
361398 if key in settings_metadata :
362399 setting_info = settings_metadata [key ]
@@ -648,11 +685,7 @@ def get_categories(self) -> Dict[str, Dict[str, List[str]]]:
648685 category = setting_info .get ("category" , "Advanced" )
649686
650687 # Determine if this is a basic or advanced setting
651- # Use showInBasic for backward compatibility, but prefer subcategory field
652688 subcategory = setting_info .get ("subcategory" , "advanced" )
653- if subcategory not in ["basic" , "advanced" ]:
654- # Fallback to showInBasic for backward compatibility
655- subcategory = setting_info .get ("subcategory" , "advanced" )
656689
657690 if category in categories :
658691 categories [category ][subcategory ].append (key )
0 commit comments