envproof 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,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - run: pip install pytest hatchling
22
+ - run: pip install -e .
23
+ - run: pytest
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ *.pyc
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: envproof
3
+ Version: 0.1.0
4
+ Summary: Validate environment variables at startup with typed, clear error messages. Zero dependencies.
5
+ Project-URL: Homepage, https://github.com/CoderSufiyan/envguard
6
+ Project-URL: Repository, https://github.com/CoderSufiyan/envguard
7
+ Project-URL: Issues, https://github.com/CoderSufiyan/envguard/issues
8
+ License: MIT
9
+ Keywords: config,dotenv,env,environment,settings,validation
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+
23
+ # envguard
24
+
25
+ Validate environment variables at startup with typed, clear error messages. Zero dependencies.
26
+
27
+ ```python
28
+ from envguard import EnvGuard
29
+
30
+ class Env(EnvGuard):
31
+ DATABASE_URL: str
32
+ PORT: int = 8080
33
+ DEBUG: bool = False
34
+ ALLOWED_HOSTS: list = []
35
+
36
+ env = Env()
37
+ print(env.PORT) # 8080 (int, not string)
38
+ ```
39
+
40
+ If `DATABASE_URL` is missing, you get this instead of a cryptic `KeyError` somewhere deep in your app:
41
+
42
+ ```
43
+ EnvGuardError:
44
+ Missing required environment variables:
45
+ - DATABASE_URL (str): not set
46
+ ```
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install envguard
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ ### Subclass style (recommended)
57
+
58
+ ```python
59
+ from envguard import EnvGuard
60
+
61
+ class Env(EnvGuard):
62
+ # Required — raises if not set
63
+ DATABASE_URL: str
64
+ API_KEY: str
65
+
66
+ # Optional — uses default if not set
67
+ PORT: int = 8080
68
+ DEBUG: bool = False
69
+ LOG_LEVEL: str = "INFO"
70
+ ALLOWED_HOSTS: list = []
71
+
72
+ env = Env()
73
+ ```
74
+
75
+ ### One-liner style
76
+
77
+ ```python
78
+ from envguard import guard
79
+
80
+ env = guard(DATABASE_URL=str, PORT=int, DEBUG=bool)
81
+ print(env.DATABASE_URL)
82
+ ```
83
+
84
+ ## Supported types
85
+
86
+ | Type | Example env value | Python value |
87
+ |---------|---------------------------|--------------------------|
88
+ | `str` | `"hello"` | `"hello"` |
89
+ | `int` | `"8080"` | `8080` |
90
+ | `float` | `"3.14"` | `3.14` |
91
+ | `bool` | `"true"`, `"1"`, `"yes"` | `True` |
92
+ | `bool` | `"false"`, `"0"`, `"no"` | `False` |
93
+ | `list` | `"a,b,c"` | `["a", "b", "c"]` |
94
+
95
+ ## Error messages
96
+
97
+ All errors are collected and reported together — you won't fix one missing var only to discover another:
98
+
99
+ ```
100
+ EnvGuardError:
101
+ Missing required environment variables:
102
+ - DATABASE_URL (str): not set
103
+ - API_KEY (str): not set
104
+
105
+ Invalid environment variable values:
106
+ - PORT: expected int, got 'abc'
107
+ - DEBUG: expected bool, got 'maybe'
108
+ ```
109
+
110
+ ## Why not pydantic-settings?
111
+
112
+ `pydantic-settings` is great but pulls in Pydantic as a dependency (~2MB). `envguard` is a single file with zero dependencies — useful when you want validation without adding weight to your project.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,94 @@
1
+ # envguard
2
+
3
+ Validate environment variables at startup with typed, clear error messages. Zero dependencies.
4
+
5
+ ```python
6
+ from envguard import EnvGuard
7
+
8
+ class Env(EnvGuard):
9
+ DATABASE_URL: str
10
+ PORT: int = 8080
11
+ DEBUG: bool = False
12
+ ALLOWED_HOSTS: list = []
13
+
14
+ env = Env()
15
+ print(env.PORT) # 8080 (int, not string)
16
+ ```
17
+
18
+ If `DATABASE_URL` is missing, you get this instead of a cryptic `KeyError` somewhere deep in your app:
19
+
20
+ ```
21
+ EnvGuardError:
22
+ Missing required environment variables:
23
+ - DATABASE_URL (str): not set
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install envguard
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Subclass style (recommended)
35
+
36
+ ```python
37
+ from envguard import EnvGuard
38
+
39
+ class Env(EnvGuard):
40
+ # Required — raises if not set
41
+ DATABASE_URL: str
42
+ API_KEY: str
43
+
44
+ # Optional — uses default if not set
45
+ PORT: int = 8080
46
+ DEBUG: bool = False
47
+ LOG_LEVEL: str = "INFO"
48
+ ALLOWED_HOSTS: list = []
49
+
50
+ env = Env()
51
+ ```
52
+
53
+ ### One-liner style
54
+
55
+ ```python
56
+ from envguard import guard
57
+
58
+ env = guard(DATABASE_URL=str, PORT=int, DEBUG=bool)
59
+ print(env.DATABASE_URL)
60
+ ```
61
+
62
+ ## Supported types
63
+
64
+ | Type | Example env value | Python value |
65
+ |---------|---------------------------|--------------------------|
66
+ | `str` | `"hello"` | `"hello"` |
67
+ | `int` | `"8080"` | `8080` |
68
+ | `float` | `"3.14"` | `3.14` |
69
+ | `bool` | `"true"`, `"1"`, `"yes"` | `True` |
70
+ | `bool` | `"false"`, `"0"`, `"no"` | `False` |
71
+ | `list` | `"a,b,c"` | `["a", "b", "c"]` |
72
+
73
+ ## Error messages
74
+
75
+ All errors are collected and reported together — you won't fix one missing var only to discover another:
76
+
77
+ ```
78
+ EnvGuardError:
79
+ Missing required environment variables:
80
+ - DATABASE_URL (str): not set
81
+ - API_KEY (str): not set
82
+
83
+ Invalid environment variable values:
84
+ - PORT: expected int, got 'abc'
85
+ - DEBUG: expected bool, got 'maybe'
86
+ ```
87
+
88
+ ## Why not pydantic-settings?
89
+
90
+ `pydantic-settings` is great but pulls in Pydantic as a dependency (~2MB). `envguard` is a single file with zero dependencies — useful when you want validation without adding weight to your project.
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,4 @@
1
+ from .core import EnvGuard, EnvGuardError, guard
2
+
3
+ __all__ = ["EnvGuard", "EnvGuardError", "guard"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,84 @@
1
+ import os
2
+ from typing import Any, get_type_hints
3
+
4
+ _MISSING = object()
5
+
6
+
7
+ class EnvGuardError(Exception):
8
+ pass
9
+
10
+
11
+ class EnvGuard:
12
+ """
13
+ Subclass this and declare your env vars as type-annotated class attributes.
14
+
15
+ Required var: DATABASE_URL: str
16
+ Optional var: PORT: int = 8080
17
+ """
18
+
19
+ def __init__(self):
20
+ hints = get_type_hints(self.__class__)
21
+ missing = []
22
+ invalid = []
23
+
24
+ for name, type_ in hints.items():
25
+ if name.startswith("_"):
26
+ continue
27
+
28
+ raw = os.environ.get(name)
29
+ has_default = name in self.__class__.__dict__
30
+ default = self.__class__.__dict__.get(name, _MISSING)
31
+
32
+ if raw is None:
33
+ if not has_default:
34
+ missing.append((name, type_))
35
+ else:
36
+ setattr(self, name, default)
37
+ continue
38
+
39
+ try:
40
+ setattr(self, name, _coerce(raw, type_))
41
+ except (ValueError, TypeError):
42
+ invalid.append((name, type_.__name__, raw))
43
+
44
+ errors = []
45
+ if missing:
46
+ errors.append("Missing required environment variables:")
47
+ for name, type_ in missing:
48
+ errors.append(f" - {name} ({type_.__name__}): not set")
49
+ if invalid:
50
+ if errors:
51
+ errors.append("")
52
+ errors.append("Invalid environment variable values:")
53
+ for name, type_name, raw in invalid:
54
+ errors.append(f" - {name}: expected {type_name}, got '{raw}'")
55
+
56
+ if errors:
57
+ raise EnvGuardError("\n" + "\n".join(errors))
58
+
59
+
60
+ def _coerce(value: str, type_: type) -> Any:
61
+ if type_ is bool:
62
+ if value.lower() in ("true", "1", "yes", "on"):
63
+ return True
64
+ if value.lower() in ("false", "0", "no", "off"):
65
+ return False
66
+ raise ValueError(f"Cannot convert to bool: {value!r}")
67
+ if type_ is list:
68
+ return [v.strip() for v in value.split(",") if v.strip()]
69
+ return type_(value)
70
+
71
+
72
+ def guard(**schema) -> Any:
73
+ """
74
+ One-liner usage without subclassing:
75
+
76
+ env = guard(DATABASE_URL=str, PORT=int, DEBUG=bool)
77
+ print(env.DATABASE_URL)
78
+ """
79
+
80
+ class _Env(EnvGuard):
81
+ pass
82
+
83
+ _Env.__annotations__ = schema
84
+ return _Env()
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "envproof"
7
+ version = "0.1.0"
8
+ description = "Validate environment variables at startup with typed, clear error messages. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ keywords = ["environment", "config", "validation", "env", "dotenv", "settings"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.8",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Intended Audience :: Developers",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/CoderSufiyan/envguard"
29
+ Repository = "https://github.com/CoderSufiyan/envguard"
30
+ Issues = "https://github.com/CoderSufiyan/envguard/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["envguard"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,203 @@
1
+ import os
2
+ import pytest
3
+ from envguard import EnvGuard, EnvGuardError, guard
4
+
5
+
6
+ def set_env(**kwargs):
7
+ for k, v in kwargs.items():
8
+ os.environ[k] = str(v)
9
+
10
+
11
+ def clear_env(*keys):
12
+ for k in keys:
13
+ os.environ.pop(k, None)
14
+
15
+
16
+ # --- EnvGuard subclass style ---
17
+
18
+ class TestRequired:
19
+ def test_loads_required_str(self):
20
+ set_env(APP_NAME="myapp")
21
+
22
+ class Env(EnvGuard):
23
+ APP_NAME: str
24
+
25
+ env = Env()
26
+ assert env.APP_NAME == "myapp"
27
+ clear_env("APP_NAME")
28
+
29
+ def test_loads_required_int(self):
30
+ set_env(PORT="9000")
31
+
32
+ class Env(EnvGuard):
33
+ PORT: int
34
+
35
+ env = Env()
36
+ assert env.PORT == 9000
37
+ clear_env("PORT")
38
+
39
+ def test_loads_required_float(self):
40
+ set_env(RATE="3.14")
41
+
42
+ class Env(EnvGuard):
43
+ RATE: float
44
+
45
+ env = Env()
46
+ assert env.RATE == 3.14
47
+ clear_env("RATE")
48
+
49
+ def test_raises_on_missing_required(self):
50
+ clear_env("DATABASE_URL")
51
+
52
+ class Env(EnvGuard):
53
+ DATABASE_URL: str
54
+
55
+ with pytest.raises(EnvGuardError) as exc:
56
+ Env()
57
+ assert "DATABASE_URL" in str(exc.value)
58
+ assert "Missing" in str(exc.value)
59
+
60
+ def test_raises_on_multiple_missing(self):
61
+ clear_env("DB_URL", "API_KEY")
62
+
63
+ class Env(EnvGuard):
64
+ DB_URL: str
65
+ API_KEY: str
66
+
67
+ with pytest.raises(EnvGuardError) as exc:
68
+ Env()
69
+ assert "DB_URL" in str(exc.value)
70
+ assert "API_KEY" in str(exc.value)
71
+
72
+
73
+ class TestOptional:
74
+ def test_uses_default_when_not_set(self):
75
+ clear_env("PORT")
76
+
77
+ class Env(EnvGuard):
78
+ PORT: int = 8080
79
+
80
+ env = Env()
81
+ assert env.PORT == 8080
82
+
83
+ def test_overrides_default_when_set(self):
84
+ set_env(PORT="9000")
85
+
86
+ class Env(EnvGuard):
87
+ PORT: int = 8080
88
+
89
+ env = Env()
90
+ assert env.PORT == 9000
91
+ clear_env("PORT")
92
+
93
+ def test_optional_str_default(self):
94
+ clear_env("LOG_LEVEL")
95
+
96
+ class Env(EnvGuard):
97
+ LOG_LEVEL: str = "INFO"
98
+
99
+ env = Env()
100
+ assert env.LOG_LEVEL == "INFO"
101
+
102
+
103
+ class TestBoolCoercion:
104
+ @pytest.mark.parametrize("value", ["true", "True", "TRUE", "1", "yes", "on"])
105
+ def test_truthy_values(self, value):
106
+ set_env(DEBUG=value)
107
+
108
+ class Env(EnvGuard):
109
+ DEBUG: bool
110
+
111
+ assert Env().DEBUG is True
112
+ clear_env("DEBUG")
113
+
114
+ @pytest.mark.parametrize("value", ["false", "False", "FALSE", "0", "no", "off"])
115
+ def test_falsy_values(self, value):
116
+ set_env(DEBUG=value)
117
+
118
+ class Env(EnvGuard):
119
+ DEBUG: bool
120
+
121
+ assert Env().DEBUG is False
122
+ clear_env("DEBUG")
123
+
124
+ def test_invalid_bool_raises(self):
125
+ set_env(DEBUG="maybe")
126
+
127
+ class Env(EnvGuard):
128
+ DEBUG: bool
129
+
130
+ with pytest.raises(EnvGuardError) as exc:
131
+ Env()
132
+ assert "DEBUG" in str(exc.value)
133
+ clear_env("DEBUG")
134
+
135
+
136
+ class TestListCoercion:
137
+ def test_comma_separated_list(self):
138
+ set_env(ALLOWED_HOSTS="localhost,example.com,api.example.com")
139
+
140
+ class Env(EnvGuard):
141
+ ALLOWED_HOSTS: list
142
+
143
+ env = Env()
144
+ assert env.ALLOWED_HOSTS == ["localhost", "example.com", "api.example.com"]
145
+ clear_env("ALLOWED_HOSTS")
146
+
147
+ def test_list_strips_whitespace(self):
148
+ set_env(HOSTS=" a , b , c ")
149
+
150
+ class Env(EnvGuard):
151
+ HOSTS: list
152
+
153
+ env = Env()
154
+ assert env.HOSTS == ["a", "b", "c"]
155
+ clear_env("HOSTS")
156
+
157
+
158
+ class TestInvalidType:
159
+ def test_invalid_int_raises(self):
160
+ set_env(PORT="not-a-number")
161
+
162
+ class Env(EnvGuard):
163
+ PORT: int
164
+
165
+ with pytest.raises(EnvGuardError) as exc:
166
+ Env()
167
+ assert "PORT" in str(exc.value)
168
+ assert "int" in str(exc.value)
169
+ clear_env("PORT")
170
+
171
+ def test_reports_all_invalid_at_once(self):
172
+ set_env(PORT="bad", WORKERS="also-bad")
173
+
174
+ class Env(EnvGuard):
175
+ PORT: int
176
+ WORKERS: int
177
+
178
+ with pytest.raises(EnvGuardError) as exc:
179
+ Env()
180
+ assert "PORT" in str(exc.value)
181
+ assert "WORKERS" in str(exc.value)
182
+ clear_env("PORT", "WORKERS")
183
+
184
+
185
+ # --- guard() one-liner style ---
186
+
187
+ class TestGuard:
188
+ def test_guard_basic(self):
189
+ set_env(HOST="localhost")
190
+ env = guard(HOST=str)
191
+ assert env.HOST == "localhost"
192
+ clear_env("HOST")
193
+
194
+ def test_guard_raises_on_missing(self):
195
+ clear_env("SECRET_KEY")
196
+ with pytest.raises(EnvGuardError):
197
+ guard(SECRET_KEY=str)
198
+
199
+ def test_guard_int(self):
200
+ set_env(TIMEOUT="30")
201
+ env = guard(TIMEOUT=int)
202
+ assert env.TIMEOUT == 30
203
+ clear_env("TIMEOUT")