frozenenv 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
envclass/__init__.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import os
5
+ import pathlib
6
+ import types
7
+ import typing
8
+ from typing import Any, get_type_hints
9
+
10
+ __all__ = ["envclass", "EnvError"]
11
+ __version__ = "0.1.0"
12
+
13
+ __BOOL_TRUE = {"1", "true", "yes", "on", "enabled"}
14
+ __BOOL_FALSE = {"0", "false", "no", "off", "disabled"}
15
+
16
+
17
+
18
+ class EnvError(Exception):
19
+ """Raised when a required env var is missing or cannot be cast to the expected type."""
20
+
21
+
22
+ def _parse_dotenv(path: str | os.PathLike) -> dict[str, str]:
23
+ """Parse KEY=VALUE lines from a .env file. Returns empty dict if not found."""
24
+ result: dict[str, str] = {}
25
+ p = pathlib.Path(path)
26
+
27
+ if not p.exists():
28
+ return result
29
+
30
+ for raw in p.read_text(encoding="utf-8").splitlines():
31
+ line = raw.strip()
32
+ if not line or line.startswith("#"):
33
+ continue
34
+ if line.startswith("export "):
35
+ line = line[7:].lstrip()
36
+ if "=" not in line:
37
+ continue
38
+ key, _, value = line.partition("=")
39
+ key = key.strip()
40
+ value = value.strip()
41
+
42
+ #strip surrounding quotes if present
43
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
44
+ value = value[1:-1]
45
+ if key:
46
+ result[key] = value
47
+ return result
48
+
49
+
50
+ def _coerce(value: str, annotation: Any, name: str) -> Any:
51
+ """Cast string value to the given type annontation."""
52
+ origin = typing.get_origin(annotation)
53
+ args = typing.get_args(annotation)
54
+
55
+
56
+ # Optional[X] or X | None
57
+ if origin is typing.Union or origin is types.UnionType:
58
+ non_none = [a for a in args if a is not type(None)]
59
+ if not value and type(None) in args:
60
+ return None
61
+ return _coerce(value, non_none[0], name) if non_none else value
62
+
63
+ # bool - must come before int (bool is a subclass of int!)
64
+ if annotation is bool:
65
+ val = value.lower()
66
+ if val in __BOOL_TRUE:
67
+ return True
68
+ if val in __BOOL_FALSE:
69
+ return False
70
+ opts = ", ".join(sorted(__BOOL_TRUE | __BOOL_FALSE))
71
+ raise EnvError(f"{name}: '{value}' is not a valid bool. Use: {opts}")
72
+
73
+ # int, float, str
74
+ if annotation in (int, float, str):
75
+ try:
76
+ return annotation(value)
77
+ except (ValueError, TypeError) as exc:
78
+ raise EnvError(f"{name}: cannot cast '{value} to {annotation.__name__} ") from exc
79
+ # list[X] - comma-separated
80
+ if origin is list:
81
+ if not value:
82
+ return []
83
+ item_type = args[0] if args else str
84
+ return [_coerce(v.strip(), item_type, name) for v in value.split(",")]
85
+
86
+ # Fallback: return as string
87
+ return value
88
+
89
+ def envclass(cls=None, *, env_file: str | os.PathLike | None = ".env",override: bool = False):
90
+ """Decorator that turns a class into a typed, frozen env-var config object.
91
+
92
+ Args:
93
+ env_file: Path to a .env file to load. Defaults to '.env' in cwd.
94
+ Set to None to skip .env loading entirely.
95
+ override: If True, .env values overwrite real environment variables.
96
+ """
97
+
98
+ def wrap(klass):
99
+ # 1. Make it a frozen dataclass so config can't be mutated
100
+ dc = dataclasses.dataclass(klass, frozen=True)
101
+
102
+
103
+ # 2. Grab the resolved type hints (handles forward refs)
104
+ hints = get_type_hints(dc)
105
+
106
+ # 3. Collect defaults from the dataclass fields
107
+ defaults: dict[str, Any] = {}
108
+ for f in dataclasses.fields(dc):
109
+ if f.default is not dataclasses.MISSING:
110
+ defaults[f.name] = f.default
111
+ elif f.default_factory is not dataclasses.MISSING:
112
+ defaults[f.name] = f.default_factory()
113
+
114
+ # 4. Build a custom __init__ that reads from env
115
+ original_init = dc.__init__
116
+
117
+ def __init__(self, *, _env: dict[str, str] | None = None):
118
+ # Load .env file first
119
+ dotenv_vals: dict[str, str] = {}
120
+
121
+ if env_file is not None:
122
+ dotenv_vals = _parse_dotenv(env_file)
123
+
124
+ # Merge: real env wins unless override=True
125
+ source = dict(dotenv_vals)
126
+ if override:
127
+ source.update(os.environ)
128
+ else:
129
+ for k, v in os.environ.items():
130
+ source[k] = v
131
+
132
+ # Allow test injection via _env parameter
133
+ if _env is not None:
134
+ source.update(_env)
135
+
136
+ # Resolve each field
137
+ kwargs: dict[str, Any] = {}
138
+ missing = []
139
+
140
+ for field_name, annotation in hints.items():
141
+ raw = source.get(field_name)
142
+ if raw is not None:
143
+ kwargs[field_name] = _coerce(raw, annotation, field_name)
144
+ elif field_name in defaults:
145
+ kwargs[field_name] = defaults[field_name]
146
+ else:
147
+ missing.append(field_name)
148
+
149
+ if missing:
150
+ raise EnvError(f"Missing required env vars: {', '.join(missing)}")
151
+
152
+ # Call the frozen dataclass __init__ via object.__setattr__
153
+ for k, v in kwargs.items():
154
+ object.__setattr__(self, k, v)
155
+
156
+ dc.__init__ = __init__
157
+ return dc
158
+
159
+ if cls is None:
160
+ return wrap
161
+ return wrap(cls)
162
+
163
+
envclass/py.typed ADDED
@@ -0,0 +1 @@
1
+ # marks package as typed
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: frozenenv
3
+ Version: 0.1.0
4
+ Summary: Typed environment variables as a frozen dataclass — zero dependencies
5
+ Project-URL: Homepage, https://github.com/hudihi/frozenenv
6
+ Project-URL: Repository, https://github.com/hudihi/frozenenv
7
+ Project-URL: Issues, https://github.com/hudihi/frozenenv/issues
8
+ Author-email: Abdillah Issa <hudihudiabdillah@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Your Name
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: config,dotenv,environment,settings,typed
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Software Development :: Libraries
41
+ Classifier: Typing :: Typed
42
+ Requires-Python: >=3.10
@@ -0,0 +1,6 @@
1
+ envclass/__init__.py,sha256=lqSJ0ApukvUfjG1cPR2zfgpaYFL1XaWyaMg_HW5dgsw,5479
2
+ envclass/py.typed,sha256=fte3dLmAGxW3DU0GUxYis4sxc5D42xU8GNOMMzqDC7c,24
3
+ frozenenv-0.1.0.dist-info/METADATA,sha256=HM6StkryRel_stl3ql21Zp2p6PlszLZ8E44q-2Ielhc,2181
4
+ frozenenv-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ frozenenv-0.1.0.dist-info/licenses/LICENSE,sha256=0SJU1dVe5uk0rKC9g3BKPevyAWTYQxcqVtDVW9ORk5Y,1065
6
+ frozenenv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.