Cat Feeder  1.0.0
The Cat feeder project
Loading...
Searching...
No Matches
toml_loader.py
Go to the documentation of this file.
1r"""
2# +==== BEGIN CatFeeder =================+
3# LOGO:
4# ..............(..../\
5# ...............)..(.')
6# ..............(../..)
7# ...............\‍(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
10# animals/cats
11# /STOP
12# PROJECT: CatFeeder
13# FILE: toml_loader.py
14# CREATION DATE: 04-12-2025
15# LAST Modified: 22:14:17 14-01-2026
16# DESCRIPTION:
17# This is the backend server in charge of making the actual website work.
18# /STOP
19# COPYRIGHT: (c) Cat Feeder
20# PURPOSE: Centralized configuration management with intelligent path discovery
21# // AR
22# +==== END CatFeeder =================+
23"""
24import os
25import sys
26from pathlib import Path
27from typing import Optional, Dict, Any
28from display_tty import Disp, initialise_logger
29from .env_loader import EnvLoader
30
31if sys.version_info >= (3, 11):
32 try:
33 import tomllib as tomli
34 except ImportError as e:
35 raise ImportError(
36 "Python 3.11+ should have tomllib in stdlib. Something is wrong with your Python installation."
37 ) from e
38else:
39 try:
40 import tomli
41 except ImportError as e:
42 raise ImportError(
43 "No TOML library found. Install tomli for Python < 3.11: pip install tomli"
44 ) from e
45
46
47_EI: EnvLoader = EnvLoader()
48
49
51 """
52 Singleton configuration loader with smart path resolution.
53
54 Loads config.toml files once and caches them for application lifecycle.
55 Supports dynamic path discovery and multiple configuration sources.
56 """
57
58 _instance: Optional['TOMLLoader'] = None
59 _initialized: bool = False
60
61 debug: bool = False
62 try:
63 debug = _EI.get_environment_variable("DEBUG").lower() == "true"
64 except ValueError:
65 pass
66 disp: Disp = initialise_logger(__qualname__, False)
67
68 def __new__(cls):
69 """Ensure only one instance exists."""
70 if cls._instance is None:
71 cls._instance = super().__new__(cls)
72 return cls._instance
73
74 def __init__(self, debug: Optional[bool] = None) -> None:
75 """Initialize the TOMLLoader singleton."""
76 # ------------------------ Only initialize once ------------------------
77 if TOMLLoader._initialized:
78 self.disp.log_debug(
79 "An instance already exists, returning early"
80 )
81 return
82
83 # ------------------ Set the debug status if provided ------------------
84 if isinstance(debug, bool):
85 self.debugdebug: bool = debug
86 self.disp.update_disp_debug(self.debugdebug)
87
88 # ------------------------ The logging function ------------------------
89 self.disp.update_disp_debug(self.debugdebug)
90 self.disp.log_info("Initialising...")
91
92 # ------------------ Cache for loaded configurations ------------------
93 self._config_toml: Optional[Dict[str, Any]] = None
94 self._project_root: Optional[Path] = None
95
96 # ------------------- Mark the class as initialised -------------------
97 TOMLLoader._initialized = True
98 # ------------------ Confirm initialisation complete ------------------
99 self.disp.log_info("Initialised")
100
101 def _find_project_root(self) -> Path:
102 """Find project root by looking for marker files/directories."""
103 if self._project_root is not None:
104 return self._project_root
105
106 current = Path(__file__).resolve().parent
107 cwd = Path.cwd().resolve()
108 search_paths = [cwd, current]
109
110 markers = [
111 'docker-compose.yaml',
112 'requirements.txt',
113 'backend',
114 '.git',
115 'config.toml',
116 '.env'
117 ]
118
119 for start_path in search_paths:
120 for level in range(5): # Increased from 4 to 5 to handle libs/config depth
121 check_path = start_path
122 for _ in range(level):
123 check_path = check_path.parent
124
125 for marker in markers:
126 marker_path = check_path / marker
127 if marker_path.exists():
128 self._project_root = check_path
129 return self._project_root
130
131 self._project_root = cwd
132 return self._project_root
133
134 def _search_directory_for_file(self, directory: Path, filename: str, current_depth: int, max_depth: int) -> Optional[Path]:
135 """
136 Recursively search directory for file up to max_depth.
137
138 Args:
139 directory: Directory to search in
140 filename: Name of file to find
141 current_depth: Current recursion depth
142 max_depth: Maximum recursion depth
143
144 Returns:
145 Path to found file or None if not found
146 """
147 if current_depth > max_depth:
148 return None
149
150 target = directory / filename
151 if target.exists() and target.is_file():
152 return target
153
154 if current_depth < max_depth:
155 try:
156 for subdir in directory.iterdir():
157 if subdir.is_dir() and not subdir.name.startswith('.'):
158 result = self._search_directory_for_file(
159 subdir, filename, current_depth + 1, max_depth)
160 if result:
161 return result
162 except PermissionError:
163 pass
164
165 return None
166
167 def _find_file(self, filename: str, max_depth: int = 3) -> Optional[Path]:
168 """
169 Find a file by searching dynamically in directory tree.
170
171 Args:
172 filename: Name of file to find
173 max_depth: Maximum directory levels to search (default: 3)
174
175 Returns:
176 Path to found file or None if not found
177 """
178 root = self._find_project_root()
179 return self._search_directory_for_file(root, filename, 0, max_depth)
180
181 def update_debug(self, debug: bool) -> None:
182 """
183 Update the debug status of the loader.
184
185 Args:
186 debug: New debug status
187 """
188 self.debugdebug = debug
189 self.disp.update_disp_debug(self.debugdebug)
190 _EI.update_debug(debug)
191 self.disp.log_info(f"Debug mode set to {self.debug}")
192
193 def load_toml(self, force_reload: bool = False, custom_path: Optional[str] = None) -> Dict[str, Any]:
194 """
195 Load config.toml file and return as dictionary.
196
197 Args:
198 force_reload: Force reload even if cached (default: False)
199 custom_path: Custom path to config file (default: None)
200
201 Returns:
202 Dictionary containing parsed TOML configuration
203
204 Raises:
205 FileNotFoundError: If config.toml not found
206 OSError: If file cannot be read
207 IOError: If file read operation fails
208 """
209 if self._config_toml is not None and not force_reload:
210 return self._config_toml
211
212 config_path = None
213
214 if custom_path:
215 config_path = Path(custom_path)
216 else:
217 if '--config' in sys.argv:
218 try:
219 idx = sys.argv.index('--config')
220 if idx + 1 < len(sys.argv):
221 config_path = Path(sys.argv[idx + 1])
222 except (ValueError, IndexError):
223 pass
224
225 if config_path is None:
226 env_config = os.environ.get('CONFIG_FILE')
227 if env_config:
228 config_path = Path(env_config)
229
230 if config_path is None:
231 config_path = self._find_file('config.toml')
232
233 if config_path is None or not config_path.exists():
234 error_message = f"config.toml not found in {os.getcwd()}"
235 self.disp.log_critical(error_message)
236 raise FileNotFoundError(
237 "config.toml not found. Searched in project root and subdirectories. "
238 "Set CONFIG_FILE environment variable or pass custom_path to specify location."
239 )
240
241 try:
242 with open(config_path, 'rb') as f:
243 self._config_toml = tomli.load(f)
244 except (OSError, IOError) as e:
245 self.disp.log_warning(f"{e}", "load_config_toml")
246 raise
247
248 return self._config_toml
249
250 def load_config_toml(self, force_reload: bool = False, custom_path: Optional[str] = None) -> Dict[str, Any]:
251 """
252 Alias function for the load_toml function.
253 Load config.toml file and return as dictionary.
254
255 Args:
256 force_reload: Force reload even if cached (default: False)
257 custom_path: Custom path to config file (default: None)
258
259 Returns:
260 Dictionary containing parsed TOML configuration
261
262 Raises:
263 FileNotFoundError: If config.toml not found
264 OSError: If file cannot be read
265 IOError: If file read operation fails
266 """
267 return self.load_toml(force_reload, custom_path)
268
269 def get_toml_value(self, *keys: str, default: Any = None) -> Any:
270 """
271 Get a value from config.toml using nested keys.
272
273 Args:
274 *keys: Nested keys to traverse (e.g., 'database', 'host')
275 default: Default value if key not found (default: None)
276
277 Returns:
278 Value at specified key path or default if not found
279 """
280 config = self.load_config_toml()
281
282 result = config
283 for key in keys:
284 if isinstance(result, dict) and key in result:
285 result = result[key]
286 else:
287 return default
288
289 return result
290
291 def get_config_value(self, *keys: str, default: Any = None) -> Any:
292 """
293 Alias function of get_toml_value
294 Get a value from config.toml using nested keys.
295
296 Args:
297 *keys: Nested keys to traverse (e.g., 'database', 'host')
298 default: Default value if key not found (default: None)
299
300 Returns:
301 Value at specified key path or default if not found
302 """
303 return self.get_config_value(*keys, default)
304
305 def clear_cache(self) -> None:
306 """Clear cached configuration."""
307 self._config_toml = None
308 self._project_root = None
309
310 def get_project_root(self) -> Path:
311 """Get the project root directory."""
312 return self._find_project_root()
313
314 def _ensure_loaded(self) -> None:
315 """Make sure tha the internal toml file is loaded and ready to be used
316
317 Raises:
318 RuntimeError: If the internal toml configuration could not be loaded.
319 """
320 error_message: str = "No toml configuration loaded"
321 if not self._config_toml:
322 try:
323 self.load_toml()
324 except (FileNotFoundError, OSError, IOError) as e:
325 self.disp.log_error(error_message)
326 raise RuntimeError(error_message) from e
327 if not self._config_toml:
328 self.disp.log_error(error_message)
329 raise RuntimeError(error_message)
330
331 def get_toml_variable(self, section: str, key: str, default=None, *, toml_conf: Optional[Dict[str, Optional[str]]] = None) -> Any:
332 """
333 Get the value of a configuration variable from the TOML file.
334
335 Args:
336 section (str): The section of the TOML file to search in.
337 key (str): The key within the section to fetch.
338 default: The default value to return if the key is not found. Defaults to None.
339 toml_conf (dict, optional): The loaded TOML configuration as a dictionary. Defaults to None.
340
341 Returns:
342 str: The value of the configuration variable, or the default value if the key is not found.
343
344 Raises:
345 KeyError: If the section is not found in the TOML configuration.
346 RuntimeError: If the internally determined toml file has not been loaded and not alternate configuration is specified.
347 """
348 if not toml_conf:
349 self._ensure_loaded()
350 current_section = self._config_toml
351 else:
352 current_section = toml_conf
353 try:
354 keys = section.split('.')
355
356 for k in keys:
357 if k in current_section:
358 current_section = current_section[k]
359 else:
360 error_message: str = f"Section '{section}' not found in TOML configuration."
361 self.disp.log_error(error_message)
362 raise KeyError(error_message)
363
364 if key in current_section:
365 msg = f"current_section[{key}] = {current_section[key]} : "
366 msg += f"{type(current_section[key])}"
367 self.disp.log_debug(msg)
368 if current_section[key] == "none":
369 self.disp.log_debug(
370 "The value none has been converted to None."
371 )
372 return None
373 return current_section[key]
374 if default is None:
375 msg = f"Key '{key}' not found in section '{section}' "
376 msg += "of TOML configuration."
377 self.disp.log_error(msg)
378 raise KeyError(msg)
379 return default
380
381 except KeyError as e:
382 self.disp.log_warning(f"{e}")
383 return default
384
385
386def load_config() -> Dict[str, Any]:
387 """
388 Load config.toml (cached).
389
390 Returns:
391 Dictionary containing parsed TOML configuration
392
393 Raises:
394 FileNotFoundError: If config.toml not found
395 """
396 loader = TOMLLoader()
397 return loader.load_config_toml()
398
399
400def get_config(*keys: str, default: Any = None) -> Any:
401 """
402 Get config.toml value using nested keys.
403
404 Args:
405 *keys: Nested keys to traverse (e.g., 'database', 'host')
406 default: Default value if key not found (default: None)
407
408 Returns:
409 Value at specified key path or default if not found
410 """
411 loader = TOMLLoader()
412 return loader.get_config_value(*keys, default=default)
413
414
415def get_project_root() -> Path:
416 """Get the project root directory."""
417 loader = TOMLLoader()
418 return loader.get_project_root()
419
420
421def refresh_debug(debug_mode_default: bool = False) -> bool:
422 """Refresh the debug status from environment variable."""
423 env_vars: list[str] = ["DEBUG", "DEBUG_MODE"]
424 toml_locations: list[str] = [
425 "Server_configuration.debug_mode",
426 "Server_configuration.debug",
427 "Server_configuration",
428 "debug_mode",
429 "debug",
430 ""
431 ]
432 toml_vars: list[str] = [
433 "debug_mode",
434 "debug"
435 ]
436 loader = TOMLLoader()
437 debug: bool = loader.debug
438 if debug_mode_default is True:
439 loader.update_debug(True)
440 _EI.update_debug(True)
441 loader.disp.log_debug("Debug mode enabled by default.")
442 return True
443 if debug:
444 loader.disp.log_debug("Debug mode already enabled, no refresh needed.")
445 return debug
446 loader.disp.log_info("Refreshing debug mode from configuration.")
447 for env_var in env_vars:
448 try:
449 debug = _EI.get_environment_variable(env_var).lower() == "true"
450 _EI.disp.log_debug(
451 f"Debug mode from ENV: {debug}, var name: '{env_var}'"
452 )
453 except ValueError:
454 pass
455 if debug:
456 _EI.update_debug(debug)
457 loader.update_debug(debug)
458 loader.disp.log_debug("Debug mode enabled from ENV.")
459 return debug
460 for location in toml_locations:
461 for toml_var_node in toml_vars:
462 tmp_debug = loader.get_toml_variable(
463 location, toml_var_node, None
464 )
465 if isinstance(tmp_debug, bool) and tmp_debug is True:
466 debug = tmp_debug
467 loader.update_debug(debug)
468 _EI.update_debug(debug)
469 _EI.disp.log_debug(
470 f"Debug mode found in TOML at {location}.{toml_var_node}"
471 )
472 return debug
473 tmp_debug = loader.get_toml_variable(
474 location, toml_var_node, None
475 )
476 if isinstance(tmp_debug, bool) and tmp_debug is True:
477 debug = tmp_debug
478 loader.update_debug(debug)
479 _EI.update_debug(debug)
480 _EI.disp.log_debug(
481 f"Debug mode found in TOML at {location}.{toml_var_node}"
482 )
483 return debug
484 return debug
Any get_toml_value(self, *str keys, Any default=None)
Optional[Path] _search_directory_for_file(self, Path directory, str filename, int current_depth, int max_depth)
Any get_toml_variable(self, str section, str key, default=None, *, Optional[Dict[str, Optional[str]]] toml_conf=None)
Optional[Path] _find_file(self, str filename, int max_depth=3)
Dict[str, Any] load_toml(self, bool force_reload=False, Optional[str] custom_path=None)
None __init__(self, Optional[bool] debug=None)
Dict[str, Any] load_config_toml(self, bool force_reload=False, Optional[str] custom_path=None)
Any get_config_value(self, *str keys, Any default=None)
bool refresh_debug(bool debug_mode_default=False)
Any get_config(*str keys, Any default=None)