Cat Feeder  1.0.0
The Cat feeder project
Loading...
Searching...
No Matches
env_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: env_loader.py
14# CREATION DATE: 04-12-2025
15# LAST Modified: 21:52:55 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:
21# Centralized .env management with intelligent path discovery.
22# Singleton environment variable loader with smart .env file discovery and caching.
23# /STOP
24# // AR
25# +==== END CatFeeder =================+
26"""
27
28import os
29import sys
30from pathlib import Path
31from typing import Optional, Dict
32from display_tty import Disp, initialise_logger
33
34
36 """
37 Singleton .env file loader with smart path resolution.
38
39 Loads .env files once and caches environment variables for application lifecycle.
40 Supports dynamic path discovery and multiple configuration sources.
41 """
42
43 _instance: Optional['EnvLoader'] = None
44 _initialized: bool = False
45
46 debug: bool = False
47 disp: Disp = initialise_logger(__qualname__, False)
48
49 def __new__(cls):
50 """Ensure only one instance exists."""
51 if cls._instance is None:
52 cls._instance = super().__new__(cls)
53 return cls._instance
54
55 def __init__(self, debug: Optional[bool] = None) -> None:
56 """Initialize the EnvLoader singleton."""
57 # ------------------------ Only initialize once ------------------------
58 if EnvLoader._initialized:
59 self.disp.log_debug(
60 "An instance already exists, returning early"
61 )
62 return
63
64 # ------------------ Set the debug status if provided ------------------
65 if isinstance(debug, bool):
66 self.debug: bool = debug
67 self.disp.update_disp_debug(self.debug)
68
69 # ------------------------ The logging function ------------------------
70 self.disp.update_disp_debug(self.debug)
71 self.disp.log_info("Initialising...")
72
73 # --------------- Cache for loaded environment variables ---------------
74 self._env_vars_env_vars: Optional[Dict[str, str]] = None
75 self._project_root: Optional[Path] = None
76
77 # ------------------- Mark the class as initialised -------------------
78 EnvLoader._initialized = True
79
80 # ------------- Actively load environment file at init -----------------
81 try:
82 self.load_env_file()
83 self.disp.log_info("Environment file loaded successfully")
84 except FileNotFoundError:
85 self.disp.log_warning("No .env file found, using os.environ only")
86 self._env_vars_env_vars = dict(os.environ)
87 except (OSError, IOError) as e:
88 self.disp.log_warning(f"Could not load environment file: {e}")
89 self._env_vars_env_vars = dict(os.environ)
90
91 # ------------------ Confirm initialisation complete ------------------
92 self.disp.log_info("Initialised")
93
94 def _find_project_root(self) -> Path:
95 """Find project root by looking for marker files/directories."""
96 if self._project_root is not None:
97 return self._project_root
98
99 current = Path(__file__).resolve().parent
100 self.disp.log_debug(f"current={current}")
101 cwd = Path.cwd().resolve()
102 self.disp.log_debug(f"cwd={cwd}")
103 # Check cwd first since it's usually the project root
104 search_paths = [cwd, current]
105 self.disp.log_debug(f"search_paths={search_paths}")
106
107 markers = [
108 'docker-compose.yaml',
109 'requirements.txt',
110 'backend',
111 '.git',
112 'config.toml',
113 '.env'
114 ]
115 self.disp.log_debug(f"markers={markers}")
116
117 for start_path in search_paths:
118 for level in range(5): # Increased from 4 to 5 to handle libs/config depth
119 check_path = start_path
120 for _ in range(level):
121 check_path = check_path.parent
122
123 for marker in markers:
124 marker_path = check_path / marker
125 if marker_path.exists():
126 self._project_root = check_path
127 return self._project_root
128
129 self._project_root = cwd
130 return self._project_root
131
132 def _search_directory_for_env(self, directory: Path, current_depth: int, max_depth: int) -> Optional[Path]:
133 """
134 Recursively search directory for .env file up to max_depth.
135
136 Args:
137 directory: Directory to search in
138 current_depth: Current recursion depth
139 max_depth: Maximum recursion depth
140
141 Returns:
142 Path to found .env file or None if not found
143 """
144 if current_depth > max_depth:
145 return None
146
147 self.disp.log_debug(
148 f"Searching in {directory} at depth {current_depth}")
149 for env_name in ['.env', 'tmp.env']:
150 target = directory / env_name
151 self.disp.log_debug(
152 f"Checking {target}: exists={target.exists()}, is_file={target.is_file() if target.exists() else 'N/A'}")
153 if target.exists() and target.is_file():
154 self.disp.log_debug(f"Found env file: {target}")
155 return target
156
157 if current_depth < max_depth:
158 try:
159 for subdir in directory.iterdir():
160 if subdir.is_dir() and not subdir.name.startswith('.'):
161 result = self._search_directory_for_env(
162 subdir, current_depth + 1, max_depth)
163 if result:
164 return result
165 except PermissionError:
166 pass
167
168 return None
169
170 def _find_env_file(self, max_depth: int = 5) -> Optional[Path]:
171 """
172 Find .env file dynamically in directory tree.
173
174 Args:
175 max_depth: Maximum directory levels to search (default: 3)
176
177 Returns:
178 Path to found .env file or None if not found
179 """
180 root = self._find_project_root()
181 self.disp.log_debug(f"Project root for env search: {root}")
182 result = self._search_directory_for_env(root, 0, max_depth)
183 self.disp.log_debug(f"Env search result: {result}")
184 return result
185
186 def update_debug(self, debug: bool) -> None:
187 """
188 Update debug status of the loader.
189
190 Args:
191 debug: New debug status
192 """
193 self.debug = debug
194 self.disp.update_disp_debug(debug)
195 self.disp.log_info(f"Debug mode set to {debug}")
196
197 def load_env_file(self, force_reload: bool = False, merge_os_environ: bool = True, custom_path: Optional[str] = None) -> Dict[str, str]:
198 """
199 Load .env file and return as dictionary.
200
201 Args:
202 force_reload: Force reload even if cached (default: False)
203 merge_os_environ: Merge with os.environ variables (default: True)
204 custom_path: Custom path to .env file (default: None)
205
206 Returns:
207 Dictionary containing environment variables
208
209 Raises:
210 FileNotFoundError: If .env file not found
211 OSError: If file cannot be read
212 IOError: If file read operation fails
213 """
214 if self._env_vars_env_vars is not None and not force_reload:
215 return self._env_vars_env_vars
216
217 env_path = None
218
219 if custom_path:
220 env_path = Path(custom_path)
221 else:
222 if '--env' in sys.argv:
223 try:
224 idx = sys.argv.index('--env')
225 if idx + 1 < len(sys.argv):
226 env_path = Path(sys.argv[idx + 1])
227 except (ValueError, IndexError):
228 pass
229
230 if env_path is None:
231 env_env = os.environ.get('ENV_FILE')
232 if env_env:
233 env_path = Path(env_env)
234
235 if env_path is None:
236 env_path = self._find_env_file()
237
238 if env_path is None or not env_path.exists():
239 error_message = f".env file not found in {env_path}"
240 self.disp.log_critical(error_message)
241 raise FileNotFoundError(
242 ".env file not found. Searched in project root and subdirectories. "
243 "Set ENV_FILE environment variable or pass custom_path to specify location."
244 )
245
246 if merge_os_environ:
247 self._env_vars_env_vars = dict(os.environ)
248 else:
250
251 try:
252 env_file_vars = self._parse_env_file(env_path)
253 self._env_vars_env_vars.update(env_file_vars)
254 except (OSError, IOError) as e:
255 self.disp.log_warning(f"{e}")
256 raise
257
258 return self._env_vars_env_vars
259
260 def _parse_env_file(self, env_path: Path) -> Dict[str, str]:
261 """
262 Parse .env file and return as dictionary.
263
264 Args:
265 env_path: Path to .env file
266
267 Returns:
268 Dictionary of key-value pairs from .env file
269 """
270 env_vars = {}
271
272 with open(env_path, 'r', encoding='utf-8') as f:
273 for line in f:
274 line = line.strip()
275
276 if not line or line.startswith('#'):
277 continue
278
279 if '=' not in line:
280 continue
281
282 key, _, value = line.partition('=')
283 key = key.strip()
284 value = value.strip()
285
286 if len(value) >= 2:
287 if (
288 value.startswith('"') and value.endswith('"')
289 ) or (
290 value.startswith("'") and value.endswith("'")
291 ):
292 value = value[1:-1]
293
294 env_vars[key] = value
295
296 return env_vars
297
298 def get_env_value(self, key: str, default: Optional[str] = None) -> Optional[str]:
299 """
300 Get a value from .env file.
301
302 Args:
303 key: Environment variable key
304 default: Default value if key not found (default: None)
305
306 Returns:
307 Environment variable value or default if not found
308 """
309 env_vars = self.load_env_file()
310 return env_vars.get(key, default)
311
312 def apply_to_os_environ(self) -> None:
313 """Apply loaded .env variables to os.environ."""
314 env_vars = self.load_env_file(merge_os_environ=False)
315 os.environ.update(env_vars)
316
317 def clear_cache(self) -> None:
318 """Clear cached environment variables."""
319 self._env_vars_env_vars = None
320 self._project_root = None
321
322 def get_project_root(self) -> Path:
323 """Get the project root directory."""
324 return self._find_project_root()
325
326 def get_environment_variable(self, variable_name: str) -> str:
327 """
328 Get the content of an environment variable.
329
330 Args:
331 variable_name: Name of the environment variable to retrieve
332
333 Returns:
334 Value of the environment variable
335
336 Raises:
337 ValueError: If no environment file is loaded
338 ValueError: If variable not found in environment
339 """
340 if self._env_vars_env_vars is None:
341 raise ValueError(
342 "No environment file loaded."
343 )
344 data = self._env_vars_env_vars.get(variable_name, None)
345 if data is None:
346 error_msg = f"Variable '{variable_name}' not found in the environment"
347 raise ValueError(error_msg)
348 return data
349
350
351# Backwards compatibility - global ENV dictionary
352EnvLoader.debug = True
353_loader_instance = EnvLoader()
354try:
355 ENV = _loader_instance.load_env_file()
356except FileNotFoundError:
357 ENV = {}
358
359
360def load_env(merge_os_environ: bool = True) -> Dict[str, str]:
361 """
362 Load .env file (cached).
363
364 Args:
365 merge_os_environ: Merge with os.environ variables (default: True)
366
367 Returns:
368 Dictionary containing environment variables
369
370 Raises:
371 FileNotFoundError: If .env file not found
372 """
373 loader = EnvLoader()
374 return loader.load_env_file(merge_os_environ=merge_os_environ)
375
376
377def get_env(key: str, default: Optional[str] = None) -> Optional[str]:
378 """
379 Get .env value.
380
381 Args:
382 key: Environment variable key
383 default: Default value if key not found (default: None)
384
385 Returns:
386 Environment variable value or default if not found
387 """
388 loader = EnvLoader()
389 return loader.get_env_value(key, default)
390
391
392def apply_env() -> None:
393 """Apply loaded .env variables to os.environ."""
394 loader = EnvLoader()
395 loader.apply_to_os_environ()
396
397
398def get_environment_variable(variable_name: str) -> str:
399 """
400 Get the content of an environment variable (backwards compatibility).
401
402 Args:
403 variable_name: Name of the environment variable to retrieve
404
405 Returns:
406 Value of the environment variable
407
408 Raises:
409 ValueError: If no environment file is loaded
410 ValueError: If variable not found in environment
411 """
412 loader = EnvLoader()
413 return loader.get_environment_variable(variable_name)
Dict[str, str] load_env_file(self, bool force_reload=False, bool merge_os_environ=True, Optional[str] custom_path=None)
Optional[Path] _search_directory_for_env(self, Path directory, int current_depth, int max_depth)
Dict[str, str] _parse_env_file(self, Path env_path)
Optional[str] get_env_value(self, str key, Optional[str] default=None)
Optional[Path] _find_env_file(self, int max_depth=5)
str get_environment_variable(self, str variable_name)
None __init__(self, Optional[bool] debug=None)
Definition env_loader.py:55
str get_environment_variable(str variable_name)
Optional[str] get_env(str key, Optional[str] default=None)
Dict[str, str] load_env(bool merge_os_environ=True)