|
1 |
| -""" |
2 |
| -This code defines a Python class MagicConfig which inherits from a dictionary object, and can be used to manage configuration data by loading it |
3 |
| -from environment variables and an optional .env file. |
4 |
| -""" |
| 1 | +from __future__ import annotations |
| 2 | + |
5 | 3 | import json
|
6 | 4 | import os
|
7 |
| -import sys |
8 |
| -import builtins |
9 |
| -from dotenv import dotenv_values |
| 5 | +from pathlib import Path |
| 6 | +from typing import Any, Self, Final |
| 7 | + |
| 8 | +from dotenv import load_dotenv, dotenv_values |
10 | 9 |
|
11 | 10 |
|
12 |
| -# @singleton |
13 | 11 | class MagicConfig(dict):
|
14 | 12 | """
|
15 |
| - MagicConfig magic class :) |
16 |
| - Define a singleton MagicConfig class which inherits from a dictionary object. |
| 13 | + Singleton configuration class loading settings from .env files and environment variables. |
| 14 | + Supports: |
| 15 | + - ENV_FILE environment variable for custom .env path |
| 16 | + - env_file argument in constructor |
| 17 | + - arbitrary extra kwargs/data passed on instantiation |
| 18 | + - automatic .env loading from current working directory |
| 19 | + - type casting based on magic.config file |
| 20 | + - automatic database URI generation |
17 | 21 | """
|
18 |
| - # Declare a private class variable for the singleton instance. |
19 |
| - __instance: 'MagicConfig' = None |
20 |
| - # Declare a private class variable for the configuration data. |
21 |
| - __data: dict = {} |
22 |
| - # Declare a private class variable for the .env file. |
23 |
| - __env_file: str = None |
24 |
| - |
25 |
| - def set_attr(self, key: str, t: str = "str") -> None: |
26 |
| - """ |
27 |
| - Set attribute to object |
28 |
| - Define a method to set attributes with default values. |
29 |
| - """ |
30 |
| - key = key.upper() |
31 |
| - cast = getattr(builtins, t) |
32 |
| - |
33 |
| - match cast: |
34 |
| - case "int": |
35 |
| - default = 0 |
36 |
| - case "float": |
37 |
| - default = 0.0 |
38 |
| - case "bool": |
39 |
| - default = False |
40 |
| - case "str": |
41 |
| - default = "" |
42 |
| - case _: |
43 |
| - default = None |
44 |
| - |
45 |
| - val = os.getenv(key, default) |
46 |
| - if val is None: |
47 |
| - self.__data[key] = default |
48 |
| - return |
49 |
| - |
50 |
| - if cast == "obj": |
51 |
| - self.__data[key] = json.loads(val) |
52 |
| - else: |
53 |
| - self.__data[key] = cast(val) |
54 |
| - |
55 |
| - def __new__(cls, *args, **kwargs) -> 'MagicConfig': |
56 |
| - """Create singleton instance of MagicConfig""" |
57 |
| - if cls.__instance: |
58 |
| - return cls.__instance |
59 |
| - cls.__instance = super(MagicConfig, cls).__new__(cls, *args, **kwargs) |
60 |
| - return cls.__instance |
61 | 22 |
|
62 |
| - def __init__(self, data: dict = {}, env_file=None, **kwargs) -> None: |
63 |
| - """ |
64 |
| - Constructor |
65 |
| - """ |
| 23 | + _instance: MagicConfig | None = None |
| 24 | + _initialized: bool = False |
| 25 | + |
| 26 | + # Mapping of type names to (cast function, default value) |
| 27 | + _TYPE_MAP: Final[dict[str, tuple[callable[[str], Any], Any]]] = { |
| 28 | + "int": (int, 0), |
| 29 | + "float": (float, 0.0), |
| 30 | + "bool": (lambda x: x.lower() in ("1", "true", "yes"), False), |
| 31 | + "str": (str, ""), |
| 32 | + "obj": (json.loads, None), |
| 33 | + } |
| 34 | + |
| 35 | + # Templates for generating database URIs |
| 36 | + _DB_TEMPLATES: Final[dict[str, str]] = { |
| 37 | + "MONGO": "mongodb://{MONGO_USER}:{MONGO_PWD}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB}?authSource=admin&tls=false", |
| 38 | + "MYSQL": "jdbc:mysql://{MYSQL_USER}:{MYSQL_PWD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}", |
| 39 | + } |
| 40 | + |
| 41 | + def __new__(cls, *args, **kwargs) -> Self: |
| 42 | + # Accept arbitrary args/kwargs so that __new__ won't |
| 43 | + # throw TypeError when user passes BASE_DIR or other params. |
| 44 | + if cls._instance is None: |
| 45 | + cls._instance = super().__new__(cls) |
| 46 | + return cls._instance |
| 47 | + |
| 48 | + def __init__( |
| 49 | + self, |
| 50 | + data: dict[str, Any] | None = None, |
| 51 | + env_file: str | None = None, |
| 52 | + **kwargs: Any, |
| 53 | + ) -> None: |
| 54 | + # Prevent re-initialization in the singleton |
| 55 | + if self._initialized: |
| 56 | + return |
66 | 57 | super().__init__()
|
67 |
| - |
68 |
| - if len(kwargs) > 0: |
69 |
| - data.update(kwargs) |
70 |
| - |
71 |
| - if env_file is not None: |
72 |
| - self.__env_file = env_file |
73 |
| - |
74 |
| - if len(data) > 0: |
75 |
| - for key, val in data.items(): |
76 |
| - self.__data[key.upper()] = val |
77 |
| - |
78 |
| - if self.__env_file is None: |
79 |
| - run_dir = os.path.dirname(os.path.realpath(sys.argv[0])) |
80 |
| - file = f"{run_dir}/.env" |
81 |
| - |
82 |
| - if not os.path.exists(file): |
83 |
| - run_dir = os.path.dirname(os.path.realpath(__file__)) |
84 |
| - file = f"{run_dir}/.env" |
85 |
| - |
86 |
| - self.__data.update(dict(dotenv_values(file))) |
| 58 | + self._initialized = True |
| 59 | + |
| 60 | + # 1) Merge positional data dict and keyword args |
| 61 | + # so that Config({'A':1}, B=2, BASE_DIR=...) works. |
| 62 | + merged = {} |
| 63 | + if data: |
| 64 | + merged |= data # PEP 584 dict union |
| 65 | + if kwargs: |
| 66 | + merged |= kwargs |
| 67 | + # store everything upper-cased |
| 68 | + self |= {key.upper(): value for key, value in merged.items()} |
| 69 | + |
| 70 | + # 2) Determine which .env file to load: |
| 71 | + # priority: ENV_FILE env var > env_file arg > default .env in cwd |
| 72 | + effective_env: str | None = os.getenv("ENV_FILE") or env_file |
| 73 | + |
| 74 | + if effective_env: |
| 75 | + load_dotenv(effective_env) |
| 76 | + raw_env = dotenv_values(effective_env) |
87 | 77 | else:
|
88 |
| - self.__data.update(dict(dotenv_values(self.__env_file))) |
89 |
| - |
90 |
| - list_of_env_vars_file = os.path.realpath(os.path.dirname(os.path.realpath(sys.argv[0])) + "/magic.config") |
91 |
| - if os.path.exists(list_of_env_vars_file): |
92 |
| - for key, val in dotenv_values(list_of_env_vars_file).items(): |
93 |
| - self.set_attr(key, val) |
94 |
| - |
95 |
| - def init(self, **kwargs) -> None: |
96 |
| - """Re init class constructor""" |
97 |
| - self.__data = {} |
98 |
| - self.__init__(**kwargs) |
99 |
| - |
100 |
| - def __getattr__(self, key: str) -> str | int: |
101 |
| - """Get attribute from config object like""" |
102 |
| - return self.__getitem__(key) |
103 |
| - |
104 |
| - def dburi_generator(self, key: str) -> str | None: |
105 |
| - """Generate db uri""" |
106 |
| - if "MONGO_URL" == key: |
107 |
| - return ( |
108 |
| - f"mongodb://{self.__data['MONGO_USER']}" |
109 |
| - f":{self.__data['MONGO_PWD']}" |
110 |
| - f"@{self.__data['MONGO_HOST']}" |
111 |
| - f":{self.__data['MONGO_PORT']}" |
112 |
| - f"/{self.__data['MONGO_DB']}" |
113 |
| - "?authSource=admin&tls=false" |
114 |
| - ) |
115 |
| - elif "MYSQL_URL" == key: |
116 |
| - return ( |
117 |
| - f"jdbc:mysql://{self.__data['MYSQL_USER']}" |
118 |
| - f":{self.__data['MYSQL_PWD']}" |
119 |
| - f"@{self.__data['MYSQL_HOST']}" |
120 |
| - f":{self.__data['MYSQL_PORT']}" |
121 |
| - f"/{self.__data['MYSQL_DB']}" |
122 |
| - ) |
123 |
| - return None |
124 |
| - |
125 |
| - def __getitem__(self, key: str): |
126 |
| - """Get item from config list like""" |
127 |
| - key = key.upper() |
128 |
| - |
129 |
| - if val := self.dburi_generator(key): |
130 |
| - return val |
131 |
| - |
132 |
| - if val := self.__data.get(key, None): |
133 |
| - return val |
134 |
| - |
135 |
| - if val := os.getenv(key): |
136 |
| - return val |
137 |
| - |
138 |
| - return None |
| 78 | + load_dotenv() |
| 79 | + raw_env = dotenv_values() |
| 80 | + |
| 81 | + # 3) Load all .env values into the config dict |
| 82 | + if raw_env: |
| 83 | + self |= {key.upper(): value for key, value in raw_env.items() if value is not None} |
| 84 | + |
| 85 | + # 4) Apply type casting based on magic.config |
| 86 | + cfg_path = Path.cwd() / "magic.config" |
| 87 | + if cfg_path.exists(): |
| 88 | + type_map = dotenv_values(cfg_path) |
| 89 | + for key, typ in type_map.items(): |
| 90 | + key_up = key.upper() |
| 91 | + raw_val = os.getenv(key_up) |
| 92 | + if raw_val is None: |
| 93 | + continue |
| 94 | + item = self._TYPE_MAP.get(typ.lower()) |
| 95 | + if item is None: |
| 96 | + continue |
| 97 | + cast_fn, default = item |
| 98 | + try: |
| 99 | + self[key_up] = cast_fn(raw_val) |
| 100 | + except Exception: |
| 101 | + self[key_up] = default |
| 102 | + |
| 103 | + def __getitem__(self, key: str) -> Any: |
| 104 | + key_up = key.upper() |
| 105 | + |
| 106 | + # 1) Auto-generate database URI if key ends with "_URL" |
| 107 | + if key_up.endswith("_URI"): |
| 108 | + prefix = key_up.removesuffix("_URI") # PEP 616 |
| 109 | + if tpl := self._DB_TEMPLATES.get(prefix): |
| 110 | + try: |
| 111 | + return tpl.format(**self) |
| 112 | + except KeyError: |
| 113 | + pass |
| 114 | + |
| 115 | + # 2) Return stored value if present |
| 116 | + if key_up in super().keys(): |
| 117 | + return super().__getitem__(key_up) |
| 118 | + |
| 119 | + # 3) Fallback to raw environment variable |
| 120 | + return os.getenv(key_up) |
| 121 | + |
| 122 | + def __getattr__(self, name: str) -> Any: |
| 123 | + # attribute-style access for config keys |
| 124 | + if name.startswith("_"): |
| 125 | + return super().__getattribute__(name) |
| 126 | + return self[name] |
139 | 127 |
|
140 | 128 | def __delitem__(self, key: str) -> None:
|
141 |
| - """Delete item from config list like""" |
142 |
| - self.__delitem__(key) |
143 |
| - |
144 |
| - def __len__(self) -> int: |
145 |
| - """Get length of config list like""" |
146 |
| - return len(self.__data) |
147 |
| - |
148 |
| - def __iter__(self) -> iter: |
149 |
| - """Get iterator of config list like""" |
150 |
| - return iter(self.__data) |
151 |
| - |
152 |
| - def __str__(self) -> str: |
153 |
| - """Get string representation of config list like""" |
154 |
| - return str(self.__data) |
| 129 | + # delete a key (case-insensitive) |
| 130 | + super().pop(key.upper(), None) |
155 | 131 |
|
156 | 132 | def __repr__(self) -> str:
|
157 |
| - """Get representation of config list like""" |
158 |
| - return repr(self.__data) |
| 133 | + return f"{self.__class__.__name__}({super().__repr__()})" |
| 134 | + |
| 135 | + def __dir__(self) -> list[str]: |
| 136 | + """ |
| 137 | + Expose dictionary keys in dir() so that Flask |
| 138 | + config.from_object() will pick them up. |
| 139 | + """ |
| 140 | + return super().__dir__() + list(self.keys()) |
159 | 141 |
|
160 | 142 |
|
161 |
| -Config = MagicConfig() |
| 143 | +# Final singleton instance |
| 144 | +Config: Final[MagicConfig] = MagicConfig() |
0 commit comments