frozenenv 0.1.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ # Virtual environment
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Build output
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ # Python cache
12
+ __pycache__/bytecode cache
13
+ *.py[cod]
14
+ *.pyo
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+
18
+ # Environment / secrets
19
+ .envcontains
20
+ .env.*
21
+ .pypirccontains
22
+
23
+ # IDE files
24
+ .vscode/optional
25
+ .idea/
26
+
27
+ # OS junk
28
+ .DS_StoremacOS
29
+ Thumbs.db
@@ -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.
@@ -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
File without changes
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "frozenenv"
7
+ version = "0.1.0"
8
+ description = "Typed environment variables as a frozen dataclass — zero dependencies"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "Abdillah Issa", email = "hudihudiabdillah@gmail.com" }]
12
+ keywords = ["environment", "config", "dotenv", "settings", "typed"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries",
23
+ "Typing :: Typed",
24
+ ]
25
+ requires-python = ">=3.10"
26
+ dependencies = [] # ← zero deps, intentional
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/hudihi/frozenenv"
30
+ Repository = "https://github.com/hudihi/frozenenv"
31
+ Issues = "https://github.com/hudihi/frozenenv/issues"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/envclass"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -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
+
@@ -0,0 +1 @@
1
+ # marks package as typed
@@ -0,0 +1,155 @@
1
+ # tests file for envclass.py
2
+ import pytest
3
+ from envclass import envclass, EnvError
4
+
5
+
6
+ def test_reads_string(monkeypatch):
7
+ monkeypatch.setenv("HOST", "localhost")
8
+
9
+ @envclass(env_file=None)
10
+ class Config:
11
+ HOST: str
12
+
13
+ assert Config().HOST == "localhost"
14
+
15
+ def test_reads_int(monkeypatch):
16
+ monkeypatch.setenv("PORT", "9000")
17
+
18
+ @envclass(env_file=None)
19
+ class Config:
20
+ PORT: int
21
+
22
+ assert Config().PORT == 9000
23
+ assert isinstance(Config().PORT, int)
24
+
25
+ def test_reads_float(monkeypatch):
26
+ monkeypatch.setenv("RATE", "3.14")
27
+
28
+ @envclass(env_file=None)
29
+ class Config:
30
+ RATE: float
31
+
32
+ assert Config().RATE == 3.14
33
+
34
+
35
+ @pytest.mark.parametrize("val", ["true", "True", "1", "yes", "on", "enabled"])
36
+ def test_bool_true(monkeypatch, val):
37
+ monkeypatch.setenv("FLAG", val)
38
+
39
+ @envclass(env_file=None)
40
+ class Config:
41
+ FLAG: bool
42
+
43
+ assert Config().FLAG is True
44
+
45
+
46
+ @pytest.mark.parametrize("val", ["false", "False", "0", "no", "off", "disabled"])
47
+ def test_bool_false(monkeypatch, val):
48
+ monkeypatch.setenv("FLAG", val)
49
+
50
+ @envclass(env_file=None)
51
+ class Config:
52
+ FLAG: bool
53
+
54
+ assert Config().FLAG is False
55
+
56
+ def test_bool_invalid(monkeypatch):
57
+ monkeypatch.setenv("FLAG", "notabool")
58
+
59
+ @envclass(env_file=None)
60
+ class Config:
61
+ FLAG: bool
62
+
63
+ with pytest.raises(EnvError, match="FLAG"):
64
+ Config()
65
+
66
+
67
+ def test_list_of_strings(monkeypatch):
68
+ monkeypatch.setenv("ITEMS", "apple,banana, cherry")
69
+
70
+ @envclass(env_file=None)
71
+ class Config:
72
+ ITEMS: list[str]
73
+
74
+ assert Config().ITEMS == ["apple", "banana", "cherry"]
75
+
76
+ def test_list_of_ints(monkeypatch):
77
+ monkeypatch.setenv("PORTS", "8000, 9000, 10000")
78
+
79
+ @envclass(env_file=None)
80
+ class Config:
81
+ PORTS: list[int]
82
+
83
+ assert Config().PORTS == [8000, 9000, 10000]
84
+
85
+ def test_list_empty_string_gives_empty_list(monkeypatch):
86
+ monkeypatch.setenv("ITEMS", "")
87
+
88
+ @envclass(env_file=None)
89
+ class Config:
90
+ ITEMS: list[str]
91
+
92
+ assert Config().ITEMS == []
93
+
94
+
95
+ def test_default_used_when_var_missing(monkeypatch):
96
+ monkeypatch.delenv("PORT", raising=False)
97
+
98
+ @envclass(env_file=None)
99
+ class Config:
100
+ PORT: int = 8000
101
+
102
+ assert Config().PORT == 8000
103
+
104
+
105
+
106
+ def test_env_overrides_default(monkeypatch):
107
+ monkeypatch.setenv("PORT", "9000")
108
+
109
+ @envclass(env_file=None)
110
+ class Config:
111
+ PORT: int = 8000
112
+
113
+ assert Config().PORT == 9000
114
+
115
+
116
+ def test_missing_required_raises(monkeypatch):
117
+ monkeypatch.delenv("DATABASE_URL", raising=False)
118
+
119
+ @envclass(env_file=None)
120
+ class Config:
121
+ DATABASE_URL: str
122
+
123
+ with pytest.raises(EnvError, match="DATABASE_URL"):
124
+ Config()
125
+
126
+ def test_config_is_frozen(monkeypatch):
127
+ monkeypatch.setenv("PORT", "8000")
128
+
129
+ @envclass(env_file=None)
130
+ class Config:
131
+ PORT: int
132
+
133
+ cfg = Config()
134
+ with pytest.raises(Exception): # FrozenInstanceError
135
+ cfg.PORT = 9999 # type: ignore
136
+
137
+
138
+ def test_optional_present(monkeypatch):
139
+ monkeypatch.setenv("TOKEN", "abc123")
140
+
141
+ @envclass(env_file=None)
142
+ class Config:
143
+ TOKEN: str | None = None
144
+
145
+ assert Config().TOKEN == "abc123"
146
+
147
+
148
+ def test_optional_missing_gives_none(monkeypatch):
149
+ monkeypatch.delenv("TOKEN", raising=False)
150
+
151
+ @envclass(env_file=None)
152
+ class Config:
153
+ TOKEN: str | None = None
154
+
155
+ assert Config().TOKEN is None