Skip to content

Commit eb4bec1

Browse files
authored
Update lib.py
1 parent 005215d commit eb4bec1

File tree

1 file changed

+128
-145
lines changed
  • magic_config/magic_config

1 file changed

+128
-145
lines changed

magic_config/magic_config/lib.py

Lines changed: 128 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,161 +1,144 @@
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+
53
import json
64
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
109

1110

12-
# @singleton
1311
class MagicConfig(dict):
1412
"""
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
1721
"""
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
6122

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
6657
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)
8777
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]
139127

140128
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)
155131

156132
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())
159141

160142

161-
Config = MagicConfig()
143+
# Final singleton instance
144+
Config: Final[MagicConfig] = MagicConfig()

0 commit comments

Comments
 (0)