2# +==== BEGIN CatFeeder =================+
5# ...............)..(.')
7# ...............\(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
14# CREATION DATE: 04-12-2025
15# LAST Modified: 21:52:55 14-01-2026
17# This is the backend server in charge of making the actual website work.
19# COPYRIGHT: (c) Cat Feeder
21# Centralized .env management with intelligent path discovery.
22# Singleton environment variable loader with smart .env file discovery and caching.
25# +==== END CatFeeder =================+
30from pathlib
import Path
31from typing
import Optional, Dict
32from display_tty
import Disp, initialise_logger
37 Singleton .env file loader with smart path resolution.
39 Loads .env files once and caches environment variables for application lifecycle.
40 Supports dynamic path discovery and multiple configuration sources.
43 _instance: Optional[
'EnvLoader'] =
None
44 _initialized: bool =
False
47 disp: Disp = initialise_logger(__qualname__,
False)
50 """Ensure only one instance exists."""
55 def __init__(self, debug: Optional[bool] =
None) ->
None:
56 """Initialize the EnvLoader singleton."""
58 if EnvLoader._initialized:
60 "An instance already exists, returning early"
65 if isinstance(debug, bool):
66 self.
debug: bool = debug
71 self.
disp.log_info(
"Initialising...")
78 EnvLoader._initialized =
True
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")
87 except (OSError, IOError)
as e:
88 self.
disp.log_warning(f
"Could not load environment file: {e}")
92 self.
disp.log_info(
"Initialised")
95 """Find project root by looking for marker files/directories."""
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}")
104 search_paths = [cwd, current]
105 self.
disp.log_debug(f
"search_paths={search_paths}")
108 'docker-compose.yaml',
115 self.
disp.log_debug(f
"markers={markers}")
117 for start_path
in search_paths:
118 for level
in range(5):
119 check_path = start_path
120 for _
in range(level):
121 check_path = check_path.parent
123 for marker
in markers:
124 marker_path = check_path / marker
125 if marker_path.exists():
134 Recursively search directory for .env file up to max_depth.
137 directory: Directory to search in
138 current_depth: Current recursion depth
139 max_depth: Maximum recursion depth
142 Path to found .env file or None if not found
144 if current_depth > max_depth:
148 f
"Searching in {directory} at depth {current_depth}")
149 for env_name
in [
'.env',
'tmp.env']:
150 target = directory / env_name
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}")
157 if current_depth < max_depth:
159 for subdir
in directory.iterdir():
160 if subdir.is_dir()
and not subdir.name.startswith(
'.'):
162 subdir, current_depth + 1, max_depth)
165 except PermissionError:
172 Find .env file dynamically in directory tree.
175 max_depth: Maximum directory levels to search (default: 3)
178 Path to found .env file or None if not found
181 self.
disp.log_debug(f
"Project root for env search: {root}")
183 self.
disp.log_debug(f
"Env search result: {result}")
188 Update debug status of the loader.
191 debug: New debug status
194 self.
disp.update_disp_debug(debug)
195 self.
disp.log_info(f
"Debug mode set to {debug}")
197 def load_env_file(self, force_reload: bool =
False, merge_os_environ: bool =
True, custom_path: Optional[str] =
None) -> Dict[str, str]:
199 Load .env file and return as dictionary.
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)
207 Dictionary containing environment variables
210 FileNotFoundError: If .env file not found
211 OSError: If file cannot be read
212 IOError: If file read operation fails
220 env_path = Path(custom_path)
222 if '--env' in sys.argv:
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):
231 env_env = os.environ.get(
'ENV_FILE')
233 env_path = Path(env_env)
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."
254 except (OSError, IOError)
as e:
255 self.
disp.log_warning(f
"{e}")
262 Parse .env file and return as dictionary.
265 env_path: Path to .env file
268 Dictionary of key-value pairs from .env file
272 with open(env_path,
'r', encoding=
'utf-8')
as f:
276 if not line
or line.startswith(
'#'):
282 key, _, value = line.partition(
'=')
284 value = value.strip()
288 value.startswith(
'"')
and value.endswith(
'"')
290 value.startswith(
"'")
and value.endswith(
"'")
294 env_vars[key] = value
298 def get_env_value(self, key: str, default: Optional[str] =
None) -> Optional[str]:
300 Get a value from .env file.
303 key: Environment variable key
304 default: Default value if key not found (default: None)
307 Environment variable value or default if not found
310 return env_vars.get(key, default)
313 """Apply loaded .env variables to os.environ."""
315 os.environ.update(env_vars)
318 """Clear cached environment variables."""
323 """Get the project root directory."""
328 Get the content of an environment variable.
331 variable_name: Name of the environment variable to retrieve
334 Value of the environment variable
337 ValueError: If no environment file is loaded
338 ValueError: If variable not found in environment
342 "No environment file loaded."
346 error_msg = f
"Variable '{variable_name}' not found in the environment"
347 raise ValueError(error_msg)
352EnvLoader.debug =
True
355 ENV = _loader_instance.load_env_file()
356except FileNotFoundError:
360def load_env(merge_os_environ: bool =
True) -> Dict[str, str]:
362 Load .env file (cached).
365 merge_os_environ: Merge with os.environ variables (default: True)
368 Dictionary containing environment variables
371 FileNotFoundError: If .env file not found
374 return loader.load_env_file(merge_os_environ=merge_os_environ)
377def get_env(key: str, default: Optional[str] =
None) -> Optional[str]:
382 key: Environment variable key
383 default: Default value if key not found (default: None)
386 Environment variable value or default if not found
389 return loader.get_env_value(key, default)
393 """Apply loaded .env variables to os.environ."""
395 loader.apply_to_os_environ()
400 Get the content of an environment variable (backwards compatibility).
403 variable_name: Name of the environment variable to retrieve
406 Value of the environment variable
409 ValueError: If no environment file is loaded
410 ValueError: If variable not found in environment
413 return loader.get_environment_variable(variable_name)
Optional[Dict[str, str]] _env_vars
Optional[Path] _project_root
Dict[str, str] load_env_file(self, bool force_reload=False, bool merge_os_environ=True, Optional[str] custom_path=None)
Path get_project_root(self)
None update_debug(self, bool debug)
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)
Path _find_project_root(self)
None apply_to_os_environ(self)
None __init__(self, Optional[bool] debug=None)
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)