Skip to content

Commit b240dc3

Browse files
Merge pull request #3 from hmohammad2520-org/2-env-mixin
Implemented ENVMod
2 parents eb01705 + b6f80c0 commit b240dc3

2 files changed

Lines changed: 279 additions & 1 deletion

File tree

classmods/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .__version__ import get_version
2-
from ._descriptors import ConstantAttrib, RemoteAttrib
32
from ._decorators import logwrap, suppress_errors
3+
from ._descriptors import ConstantAttrib, RemoteAttrib
4+
from ._env_mod import ENVMod
45
from ._method_monitor import MethodMonitor
56

67

@@ -10,5 +11,6 @@
1011
'RemoteAttrib',
1112
'logwrap',
1213
'suppress_errors',
14+
'ENVMod',
1315
'MethodMonitor',
1416
]

classmods/_env_mod.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import os
2+
import inspect
3+
from typing import ClassVar, Optional, List, Dict, Any, Callable, Set, Type, get_type_hints
4+
from functools import wraps
5+
6+
7+
class ENVMod:
8+
"""
9+
A utility to generate .env files, auto-load environment variables from constructors,
10+
and support auto-documentation and integration with python-dotenv.
11+
"""
12+
class _Item:
13+
"""
14+
Represents a single environment variable item extracted from constructor args.
15+
"""
16+
def __init__(
17+
self,
18+
arg_name: str,
19+
class_prefix: str = '',
20+
description: Optional[List[str]] = None,
21+
required: bool = False,
22+
default: Optional[str] = None,
23+
) -> None:
24+
self._arg_name: str = arg_name
25+
self._default: str = default or ''
26+
self._required: bool = required
27+
self._description: List[str] = [line.strip() + '\n' for line in (description or [])]
28+
self._key: str = self._generate_key(class_prefix)
29+
30+
def _generate_key(self, prefix: str) -> str:
31+
clean = self._arg_name
32+
for ch in "- .":
33+
clean = clean.replace(ch, "_")
34+
return f"{prefix}_{clean.upper()}" if prefix else clean.upper()
35+
36+
class _Section:
37+
"""
38+
A group of related environment items, grouped by class name.
39+
"""
40+
def __init__(self, name: str) -> None:
41+
self.name: str = name.upper()
42+
self.items: List[ENVMod._Item] = []
43+
44+
def _add_item(self, item: 'ENVMod._Item') -> None:
45+
self.items.append(item)
46+
47+
def _generate(self) -> str:
48+
lines: List[str] = []
49+
lines.append(f"{'#' * (len(self.name) + 24)}")
50+
lines.append(f"########### {self.name} ###########")
51+
52+
for item in self.items:
53+
lines.append(f"###### {item._arg_name} {'(Required)' if item._required else ''}")
54+
lines.append("####")
55+
if item._description:
56+
lines.extend(f"## {line.strip()}" for line in item._description)
57+
lines.append(f"## Default={item._default}")
58+
lines.append("####")
59+
lines.append(f"{item._key}=")
60+
lines.append("")
61+
62+
lines.append(f"{'#' * (len(self.name) + 24)}")
63+
return "\n".join(lines)
64+
65+
class _ENVFile:
66+
"""
67+
Handles the entire .env structure with multiple sections.
68+
"""
69+
def __init__(self) -> None:
70+
self.sections: Dict[str, ENVMod._Section] = {}
71+
72+
def _get_or_create(self, name: str) -> 'ENVMod._Section':
73+
name = name.upper()
74+
if name not in self.sections:
75+
self.sections[name] = ENVMod._Section(name)
76+
return self.sections[name]
77+
78+
def _generate(self) -> str:
79+
return '\n'.join(section._generate() for section in self.sections.values())
80+
81+
def _save_as_file(self, path: str) -> None:
82+
with open(path, 'w') as f:
83+
f.write(self._generate())
84+
85+
def _get_all_keys(self) -> List[str]:
86+
return [item._key for section in self.sections.values() for item in section.items]
87+
88+
_envfile: _ENVFile = _ENVFile()
89+
_registry: Dict[Callable, Dict[str, str]] = {}
90+
_used_env_keys: ClassVar[Set[str]] = set()
91+
92+
@classmethod
93+
def register(cls, *, exclude: Optional[List[str]] = None) -> Callable:
94+
"""
95+
Decorator to register class methods for env parsing.
96+
97+
Raise:
98+
TypeError: if a object is not env parsable.
99+
100+
Example:
101+
>>> class APIService:
102+
... @ENVMod.register(exclude=['ssl_key'])
103+
... def __init__(
104+
self,
105+
host: str,
106+
port: int,
107+
username: str = None,
108+
password: str = None,
109+
ssl_key: SSLKey
110+
) -> none:
111+
... ...
112+
113+
In this example ENVMod will create env items for each object in init except ssl_key.
114+
115+
Note: Make sure you add type hints to get the same type when loading from env file.
116+
117+
"""
118+
exclude = exclude or []
119+
120+
def decorator(func: Callable) -> Callable:
121+
sig = inspect.signature(func)
122+
qualname = func.__qualname__.split('.')[0].upper()
123+
section = cls._envfile._get_or_create(qualname)
124+
arg_map: Dict[str, str] = {}
125+
126+
docstring = inspect.getdoc(func) or ""
127+
doc_lines = docstring.splitlines() if docstring else []
128+
type_hints = get_type_hints(func)
129+
130+
for param in sig.parameters.values():
131+
if param.name == 'self' or param.name in exclude:
132+
continue
133+
134+
param_type = type_hints.get(param.name, str)
135+
if param_type not in (str, int, float, bool):
136+
raise TypeError(f"Cannot register parameter '{param.name}' of type '{param_type.__name__}'")
137+
138+
param_doc = [line.strip() for line in doc_lines if param.name in line.lower()]
139+
default = (
140+
None if param.default is inspect.Parameter.empty else str(param.default)
141+
)
142+
143+
item = cls._Item(
144+
arg_name=param.name,
145+
class_prefix=qualname,
146+
description=param_doc,
147+
required=param.default is inspect.Parameter.empty,
148+
default=default,
149+
)
150+
151+
# Check for duplicates
152+
if item._key in cls._used_env_keys:
153+
raise ValueError(
154+
f"Duplicate environment key detected: '{item._key}' already registered. "
155+
f"Check other registered methods or exclude this parameter."
156+
)
157+
cls._used_env_keys.add(item._key)
158+
section._add_item(item)
159+
arg_map[param.name] = item._key
160+
161+
@wraps(func)
162+
def wrapper(*args: Any, **kwargs: Any) -> Any:
163+
return func(*args, **kwargs)
164+
165+
cls._registry[wrapper] = arg_map
166+
167+
return wrapper
168+
return decorator
169+
170+
@classmethod
171+
def load_args(cls, func: Callable) -> Dict[str, Any]:
172+
"""
173+
Load registered function/class args from environment variables.
174+
175+
Example:
176+
>>> api_service = APIService(**ENVMod.load_args(APIService.__init__))
177+
178+
In above example the ENVMod will load the registered variables and pass them to the method.
179+
180+
"""
181+
mapping = cls._registry.get(func)
182+
if mapping is None:
183+
for f, keys in cls._registry.items():
184+
if getattr(f, '__wrapped__', None) == func:
185+
mapping = keys
186+
break
187+
188+
if not mapping:
189+
raise ValueError(f'This method or function is not registerd: {func.__name__}')
190+
191+
sig = inspect.signature(func)
192+
types = get_type_hints(func)
193+
194+
def cast(value: str, _type: Type) -> Any:
195+
if _type == bool:
196+
if value.lower() in ('1', 'true', 'yes'): return True
197+
elif value.lower() in ('0', 'false', 'no'): return False
198+
else: raise ValueError(f"Casting env is not a valid bool: {value}. valid bool: '0', 'false', 'no', '1', 'true', 'yes'")
199+
return _type(value)
200+
201+
result = {}
202+
for arg, env_key in mapping.items():
203+
value = os.environ.get(env_key)
204+
if value is None:
205+
result[arg] = None
206+
continue
207+
208+
result[arg] = cast(value, types.get(arg, str))
209+
210+
return result
211+
212+
@classmethod
213+
def save_example(cls, path: str = ".env_example") -> None:
214+
"""
215+
Save an example .env file based on all registered items.
216+
"""
217+
cls._envfile._save_as_file(path)
218+
219+
@classmethod
220+
def sync_env_file(cls, path: str = ".env") -> None:
221+
"""
222+
Merge existing .env file with missing expected keys.
223+
"""
224+
expected_keys = set(cls._envfile._get_all_keys())
225+
226+
existing: Dict[str, str] = {}
227+
if os.path.exists(path):
228+
with open(path) as f:
229+
for line in f:
230+
if '=' in line and not line.strip().startswith('#'):
231+
key, value = line.strip().split('=', 1)
232+
existing[key.strip()] = value.strip()
233+
234+
new_content = ''
235+
all_keys = expected_keys.union(existing.keys())
236+
for key in sorted(all_keys):
237+
value = existing.get(key, '')
238+
new_content += f"{key}={value}\n"
239+
240+
with open(path, 'w') as f:
241+
f.write(new_content)
242+
243+
@classmethod
244+
def add(
245+
cls,
246+
section_name: str,
247+
key: str,
248+
description: Optional[List[str]] = None,
249+
default: Optional[str] = None,
250+
required: bool = False,
251+
) -> None:
252+
"""
253+
Manually add an env item not tied to a class.
254+
"""
255+
section = cls._envfile._get_or_create(section_name)
256+
item = cls._Item(
257+
arg_name=key,
258+
class_prefix=section.name,
259+
description=description,
260+
default=default,
261+
required=required,
262+
)
263+
section._add_item(item)
264+
265+
@staticmethod
266+
def load_dotenv(*args: Any, **kwargs: Any) -> None:
267+
"""
268+
Wrapper for python-dotenv, loads .env into os.environ.
269+
"""
270+
try:
271+
from dotenv import load_dotenv # type: ignore
272+
load_dotenv(*args, **kwargs)
273+
except ImportError:
274+
raise NotImplementedError(
275+
"Dependency not present. Install it with `pip install python-dotenv`."
276+
)

0 commit comments

Comments
 (0)