crashbytes-envguard 1.0.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.
- crashbytes_envguard-1.0.0/.github/CODEOWNERS +1 -0
- crashbytes_envguard-1.0.0/.github/workflows/ci.yml +23 -0
- crashbytes_envguard-1.0.0/.github/workflows/publish.yml +19 -0
- crashbytes_envguard-1.0.0/.gitignore +11 -0
- crashbytes_envguard-1.0.0/LICENSE +21 -0
- crashbytes_envguard-1.0.0/PKG-INFO +82 -0
- crashbytes_envguard-1.0.0/README.md +54 -0
- crashbytes_envguard-1.0.0/pyproject.toml +53 -0
- crashbytes_envguard-1.0.0/src/crashbytes_envguard/__init__.py +5 -0
- crashbytes_envguard-1.0.0/src/crashbytes_envguard/_core.py +220 -0
- crashbytes_envguard-1.0.0/src/crashbytes_envguard/py.typed +0 -0
- crashbytes_envguard-1.0.0/tests/test_envguard.py +227 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @CrashBytes
|
|
@@ -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.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check src/ tests/
|
|
22
|
+
- run: mypy --strict src/
|
|
23
|
+
- run: pytest --cov=crashbytes_envguard --cov-branch --cov-fail-under=90
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
- run: pip install build
|
|
18
|
+
- run: python -m build
|
|
19
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CrashBytes
|
|
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,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crashbytes-envguard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Lightweight environment variable validation with schema, type coercion, and .env loading.
|
|
5
|
+
Project-URL: Homepage, https://github.com/CrashBytes/crashbytes-envguard
|
|
6
|
+
Project-URL: Repository, https://github.com/CrashBytes/crashbytes-envguard
|
|
7
|
+
Project-URL: Issues, https://github.com/CrashBytes/crashbytes-envguard/issues
|
|
8
|
+
Author-email: CrashBytes <crashbytes@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: config,dotenv,env,environment,validation
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# crashbytes-envguard
|
|
30
|
+
|
|
31
|
+
Lightweight environment variable validation with schema, type coercion, and .env loading.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install crashbytes-envguard
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from crashbytes_envguard import create_env, s
|
|
43
|
+
|
|
44
|
+
env = create_env({
|
|
45
|
+
"PORT": s.port().default(8080),
|
|
46
|
+
"DB_URL": s.url(),
|
|
47
|
+
"DEBUG": s.boolean().default(False),
|
|
48
|
+
"LOG_LEVEL": s.string().choices("debug", "info", "warn", "error").default("info"),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
print(env["PORT"]) # 8080 (int, type-coerced)
|
|
52
|
+
print(env["DB_URL"]) # "https://..." (validated)
|
|
53
|
+
print(env["DEBUG"]) # False (bool, type-coerced)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Fails fast with **all** errors at once:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
EnvError: Environment validation failed:
|
|
60
|
+
- DB_URL: required but not set
|
|
61
|
+
- PORT: must be >= 1 (got 0)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Schema Types
|
|
65
|
+
|
|
66
|
+
| Builder | Type | Extra Validation |
|
|
67
|
+
|---------|------|-----------------|
|
|
68
|
+
| `s.string()` | `str` | — |
|
|
69
|
+
| `s.integer()` | `int` | — |
|
|
70
|
+
| `s.number()` | `float` | — |
|
|
71
|
+
| `s.boolean()` | `bool` | true/1/yes/on, false/0/no/off |
|
|
72
|
+
| `s.port()` | `int` | 1–65535 |
|
|
73
|
+
| `s.url()` | `str` | Must start with http(s):// |
|
|
74
|
+
| `s.email()` | `str` | Must be valid email format |
|
|
75
|
+
|
|
76
|
+
## Field Modifiers
|
|
77
|
+
|
|
78
|
+
`.default(value)`, `.optional()`, `.choices(...)`, `.min(n)`, `.max(n)`, `.pattern(regex)`
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# crashbytes-envguard
|
|
2
|
+
|
|
3
|
+
Lightweight environment variable validation with schema, type coercion, and .env loading.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install crashbytes-envguard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from crashbytes_envguard import create_env, s
|
|
15
|
+
|
|
16
|
+
env = create_env({
|
|
17
|
+
"PORT": s.port().default(8080),
|
|
18
|
+
"DB_URL": s.url(),
|
|
19
|
+
"DEBUG": s.boolean().default(False),
|
|
20
|
+
"LOG_LEVEL": s.string().choices("debug", "info", "warn", "error").default("info"),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
print(env["PORT"]) # 8080 (int, type-coerced)
|
|
24
|
+
print(env["DB_URL"]) # "https://..." (validated)
|
|
25
|
+
print(env["DEBUG"]) # False (bool, type-coerced)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Fails fast with **all** errors at once:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
EnvError: Environment validation failed:
|
|
32
|
+
- DB_URL: required but not set
|
|
33
|
+
- PORT: must be >= 1 (got 0)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Schema Types
|
|
37
|
+
|
|
38
|
+
| Builder | Type | Extra Validation |
|
|
39
|
+
|---------|------|-----------------|
|
|
40
|
+
| `s.string()` | `str` | — |
|
|
41
|
+
| `s.integer()` | `int` | — |
|
|
42
|
+
| `s.number()` | `float` | — |
|
|
43
|
+
| `s.boolean()` | `bool` | true/1/yes/on, false/0/no/off |
|
|
44
|
+
| `s.port()` | `int` | 1–65535 |
|
|
45
|
+
| `s.url()` | `str` | Must start with http(s):// |
|
|
46
|
+
| `s.email()` | `str` | Must be valid email format |
|
|
47
|
+
|
|
48
|
+
## Field Modifiers
|
|
49
|
+
|
|
50
|
+
`.default(value)`, `.optional()`, `.choices(...)`, `.min(n)`, `.max(n)`, `.pattern(regex)`
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crashbytes-envguard"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Lightweight environment variable validation with schema, type coercion, and .env loading."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "CrashBytes", email = "crashbytes@users.noreply.github.com" }]
|
|
13
|
+
keywords = ["env", "environment", "validation", "config", "dotenv"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest", "pytest-cov", "mypy", "ruff"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/CrashBytes/crashbytes-envguard"
|
|
31
|
+
Repository = "https://github.com/CrashBytes/crashbytes-envguard"
|
|
32
|
+
Issues = "https://github.com/CrashBytes/crashbytes-envguard/issues"
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
target-version = "py310"
|
|
36
|
+
line-length = 99
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"]
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
strict = true
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[tool.coverage.run]
|
|
49
|
+
branch = true
|
|
50
|
+
source = ["crashbytes_envguard"]
|
|
51
|
+
|
|
52
|
+
[tool.coverage.report]
|
|
53
|
+
fail_under = 90
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Environment variable validation with schema, type coercion, and .env loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnvError(Exception):
|
|
13
|
+
"""Raised when environment validation fails."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, errors: list[str]) -> None:
|
|
16
|
+
self.errors = errors
|
|
17
|
+
msg = "Environment validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
|
18
|
+
super().__init__(msg)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _FieldSpec:
|
|
23
|
+
name: str
|
|
24
|
+
coerce: type[Any]
|
|
25
|
+
required: bool = True
|
|
26
|
+
default: Any = None
|
|
27
|
+
choices: list[Any] = field(default_factory=list)
|
|
28
|
+
min_val: float | None = None
|
|
29
|
+
max_val: float | None = None
|
|
30
|
+
pattern: str | None = None
|
|
31
|
+
custom_name: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SchemaField:
|
|
35
|
+
"""Builder for a single environment variable schema field."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, coerce: type[Any] = str) -> None:
|
|
38
|
+
self._coerce = coerce
|
|
39
|
+
self._required = True
|
|
40
|
+
self._default: Any = None
|
|
41
|
+
self._choices: list[Any] = []
|
|
42
|
+
self._min_val: float | None = None
|
|
43
|
+
self._max_val: float | None = None
|
|
44
|
+
self._pattern: str | None = None
|
|
45
|
+
|
|
46
|
+
def default(self, value: Any) -> SchemaField:
|
|
47
|
+
"""Set a default value (makes the field optional)."""
|
|
48
|
+
self._default = value
|
|
49
|
+
self._required = False
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def optional(self) -> SchemaField:
|
|
53
|
+
"""Mark as optional with no default (value will be ``None`` if missing)."""
|
|
54
|
+
self._required = False
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def choices(self, *values: Any) -> SchemaField:
|
|
58
|
+
"""Restrict to a set of allowed values."""
|
|
59
|
+
self._choices = list(values)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def min(self, value: float) -> SchemaField:
|
|
63
|
+
"""Set minimum value (numeric fields)."""
|
|
64
|
+
self._min_val = value
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def max(self, value: float) -> SchemaField:
|
|
68
|
+
"""Set maximum value (numeric fields)."""
|
|
69
|
+
self._max_val = value
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def pattern(self, regex: str) -> SchemaField:
|
|
73
|
+
"""Require the value to match a regex pattern."""
|
|
74
|
+
self._pattern = regex
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def _to_spec(self, name: str) -> _FieldSpec:
|
|
78
|
+
return _FieldSpec(
|
|
79
|
+
name=name,
|
|
80
|
+
coerce=self._coerce,
|
|
81
|
+
required=self._required,
|
|
82
|
+
default=self._default,
|
|
83
|
+
choices=self._choices,
|
|
84
|
+
min_val=self._min_val,
|
|
85
|
+
max_val=self._max_val,
|
|
86
|
+
pattern=self._pattern,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class s: # noqa: N801
|
|
91
|
+
"""Schema builder — shorthand for defining field types."""
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def string() -> SchemaField:
|
|
95
|
+
return SchemaField(str)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def integer() -> SchemaField:
|
|
99
|
+
return SchemaField(int)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def number() -> SchemaField:
|
|
103
|
+
return SchemaField(float)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def boolean() -> SchemaField:
|
|
107
|
+
return SchemaField(bool)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def port() -> SchemaField:
|
|
111
|
+
"""Integer constrained to 1–65535."""
|
|
112
|
+
return SchemaField(int).min(1).max(65535)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def url() -> SchemaField:
|
|
116
|
+
"""String that must start with http:// or https://."""
|
|
117
|
+
return SchemaField(str).pattern(r"^https?://")
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def email() -> SchemaField:
|
|
121
|
+
"""String that must be a valid email format."""
|
|
122
|
+
return SchemaField(str).pattern(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _coerce_bool(raw: str) -> bool:
|
|
126
|
+
low = raw.lower()
|
|
127
|
+
if low in ("true", "1", "yes", "on"):
|
|
128
|
+
return True
|
|
129
|
+
if low in ("false", "0", "no", "off"):
|
|
130
|
+
return False
|
|
131
|
+
raise ValueError(f"Cannot convert {raw!r} to bool")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _coerce_value(raw: str, target: type[Any]) -> Any:
|
|
135
|
+
if target is bool:
|
|
136
|
+
return _coerce_bool(raw)
|
|
137
|
+
return target(raw)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_dotenv(path: str | Path = ".env") -> None:
|
|
141
|
+
"""Load variables from a .env file into ``os.environ``."""
|
|
142
|
+
p = Path(path)
|
|
143
|
+
if not p.exists():
|
|
144
|
+
return
|
|
145
|
+
for line in p.read_text().splitlines():
|
|
146
|
+
line = line.strip()
|
|
147
|
+
if not line or line.startswith("#"):
|
|
148
|
+
continue
|
|
149
|
+
if "=" not in line:
|
|
150
|
+
continue
|
|
151
|
+
key, _, value = line.partition("=")
|
|
152
|
+
key = key.strip()
|
|
153
|
+
value = value.strip()
|
|
154
|
+
# Strip surrounding quotes
|
|
155
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
156
|
+
value = value[1:-1]
|
|
157
|
+
os.environ.setdefault(key, value)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def create_env(
|
|
161
|
+
schema: dict[str, SchemaField],
|
|
162
|
+
*,
|
|
163
|
+
env_file: str | Path | None = ".env",
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""Validate environment variables against *schema*.
|
|
166
|
+
|
|
167
|
+
Loads from *env_file* first (if it exists), then validates every field.
|
|
168
|
+
Collects **all** errors before raising.
|
|
169
|
+
|
|
170
|
+
Returns a dict of coerced values.
|
|
171
|
+
"""
|
|
172
|
+
if env_file is not None:
|
|
173
|
+
load_dotenv(env_file)
|
|
174
|
+
|
|
175
|
+
errors: list[str] = []
|
|
176
|
+
result: dict[str, Any] = {}
|
|
177
|
+
|
|
178
|
+
for name, field_builder in schema.items():
|
|
179
|
+
spec = field_builder._to_spec(name) # noqa: SLF001
|
|
180
|
+
raw = os.environ.get(name)
|
|
181
|
+
|
|
182
|
+
if raw is None or raw == "":
|
|
183
|
+
if spec.required:
|
|
184
|
+
errors.append(f"{name}: required but not set")
|
|
185
|
+
continue
|
|
186
|
+
result[name] = spec.default
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
value = _coerce_value(raw, spec.coerce)
|
|
191
|
+
except (ValueError, TypeError):
|
|
192
|
+
errors.append(f"{name}: cannot convert {raw!r} to {spec.coerce.__name__}")
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
if spec.choices and value not in spec.choices:
|
|
196
|
+
errors.append(f"{name}: must be one of {spec.choices} (got {value!r})")
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if spec.min_val is not None and isinstance(value, (int, float)) and value < spec.min_val:
|
|
200
|
+
errors.append(f"{name}: must be >= {spec.min_val} (got {value})")
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
if spec.max_val is not None and isinstance(value, (int, float)) and value > spec.max_val:
|
|
204
|
+
errors.append(f"{name}: must be <= {spec.max_val} (got {value})")
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
if (
|
|
208
|
+
spec.pattern is not None
|
|
209
|
+
and isinstance(value, str)
|
|
210
|
+
and not re.match(spec.pattern, value)
|
|
211
|
+
):
|
|
212
|
+
errors.append(f"{name}: must match pattern {spec.pattern!r} (got {value!r})")
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
result[name] = value
|
|
216
|
+
|
|
217
|
+
if errors:
|
|
218
|
+
raise EnvError(errors)
|
|
219
|
+
|
|
220
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Tests for crashbytes-envguard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from crashbytes_envguard import EnvError, create_env, load_dotenv, s
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
18
|
+
"""Remove test env vars before each test."""
|
|
19
|
+
for key in ["PORT", "DB_URL", "DEBUG", "APP_NAME", "HOST", "WORKERS", "LOG_LEVEL", "EMAIL"]:
|
|
20
|
+
monkeypatch.delenv(key, raising=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestSchemaTypes:
|
|
24
|
+
def test_string(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
25
|
+
monkeypatch.setenv("APP_NAME", "myapp")
|
|
26
|
+
result = create_env({"APP_NAME": s.string()}, env_file=None)
|
|
27
|
+
assert result["APP_NAME"] == "myapp"
|
|
28
|
+
|
|
29
|
+
def test_integer(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
30
|
+
monkeypatch.setenv("PORT", "8080")
|
|
31
|
+
result = create_env({"PORT": s.integer()}, env_file=None)
|
|
32
|
+
assert result["PORT"] == 8080
|
|
33
|
+
|
|
34
|
+
def test_number(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
35
|
+
monkeypatch.setenv("WORKERS", "3.5")
|
|
36
|
+
result = create_env({"WORKERS": s.number()}, env_file=None)
|
|
37
|
+
assert result["WORKERS"] == 3.5
|
|
38
|
+
|
|
39
|
+
def test_boolean_true(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
40
|
+
for val in ["true", "1", "yes", "on", "True", "YES"]:
|
|
41
|
+
monkeypatch.setenv("DEBUG", val)
|
|
42
|
+
result = create_env({"DEBUG": s.boolean()}, env_file=None)
|
|
43
|
+
assert result["DEBUG"] is True
|
|
44
|
+
|
|
45
|
+
def test_boolean_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
46
|
+
for val in ["false", "0", "no", "off", "False", "NO"]:
|
|
47
|
+
monkeypatch.setenv("DEBUG", val)
|
|
48
|
+
result = create_env({"DEBUG": s.boolean()}, env_file=None)
|
|
49
|
+
assert result["DEBUG"] is False
|
|
50
|
+
|
|
51
|
+
def test_boolean_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
52
|
+
monkeypatch.setenv("DEBUG", "maybe")
|
|
53
|
+
with pytest.raises(EnvError, match="cannot convert"):
|
|
54
|
+
create_env({"DEBUG": s.boolean()}, env_file=None)
|
|
55
|
+
|
|
56
|
+
def test_port(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
57
|
+
monkeypatch.setenv("PORT", "443")
|
|
58
|
+
result = create_env({"PORT": s.port()}, env_file=None)
|
|
59
|
+
assert result["PORT"] == 443
|
|
60
|
+
|
|
61
|
+
def test_port_out_of_range(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
62
|
+
monkeypatch.setenv("PORT", "99999")
|
|
63
|
+
with pytest.raises(EnvError, match="must be <="):
|
|
64
|
+
create_env({"PORT": s.port()}, env_file=None)
|
|
65
|
+
|
|
66
|
+
def test_port_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
67
|
+
monkeypatch.setenv("PORT", "0")
|
|
68
|
+
with pytest.raises(EnvError, match="must be >="):
|
|
69
|
+
create_env({"PORT": s.port()}, env_file=None)
|
|
70
|
+
|
|
71
|
+
def test_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
72
|
+
monkeypatch.setenv("DB_URL", "https://db.example.com")
|
|
73
|
+
result = create_env({"DB_URL": s.url()}, env_file=None)
|
|
74
|
+
assert result["DB_URL"] == "https://db.example.com"
|
|
75
|
+
|
|
76
|
+
def test_url_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
77
|
+
monkeypatch.setenv("DB_URL", "ftp://nope")
|
|
78
|
+
with pytest.raises(EnvError, match="must match pattern"):
|
|
79
|
+
create_env({"DB_URL": s.url()}, env_file=None)
|
|
80
|
+
|
|
81
|
+
def test_email(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
82
|
+
monkeypatch.setenv("EMAIL", "user@example.com")
|
|
83
|
+
result = create_env({"EMAIL": s.email()}, env_file=None)
|
|
84
|
+
assert result["EMAIL"] == "user@example.com"
|
|
85
|
+
|
|
86
|
+
def test_email_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
87
|
+
monkeypatch.setenv("EMAIL", "not-an-email")
|
|
88
|
+
with pytest.raises(EnvError, match="must match pattern"):
|
|
89
|
+
create_env({"EMAIL": s.email()}, env_file=None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestDefaults:
|
|
93
|
+
def test_default_value(self) -> None:
|
|
94
|
+
result = create_env({"PORT": s.port().default(8080)}, env_file=None)
|
|
95
|
+
assert result["PORT"] == 8080
|
|
96
|
+
|
|
97
|
+
def test_optional_none(self) -> None:
|
|
98
|
+
result = create_env({"HOST": s.string().optional()}, env_file=None)
|
|
99
|
+
assert result["HOST"] is None
|
|
100
|
+
|
|
101
|
+
def test_env_overrides_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
102
|
+
monkeypatch.setenv("PORT", "3000")
|
|
103
|
+
result = create_env({"PORT": s.port().default(8080)}, env_file=None)
|
|
104
|
+
assert result["PORT"] == 3000
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestValidation:
|
|
108
|
+
def test_required_missing(self) -> None:
|
|
109
|
+
with pytest.raises(EnvError, match="required but not set"):
|
|
110
|
+
create_env({"PORT": s.port()}, env_file=None)
|
|
111
|
+
|
|
112
|
+
def test_empty_string_treated_as_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
113
|
+
monkeypatch.setenv("PORT", "")
|
|
114
|
+
with pytest.raises(EnvError, match="required but not set"):
|
|
115
|
+
create_env({"PORT": s.port()}, env_file=None)
|
|
116
|
+
|
|
117
|
+
def test_choices(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
118
|
+
monkeypatch.setenv("LOG_LEVEL", "info")
|
|
119
|
+
result = create_env(
|
|
120
|
+
{"LOG_LEVEL": s.string().choices("debug", "info", "warn", "error")},
|
|
121
|
+
env_file=None,
|
|
122
|
+
)
|
|
123
|
+
assert result["LOG_LEVEL"] == "info"
|
|
124
|
+
|
|
125
|
+
def test_choices_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
126
|
+
monkeypatch.setenv("LOG_LEVEL", "verbose")
|
|
127
|
+
with pytest.raises(EnvError, match="must be one of"):
|
|
128
|
+
create_env(
|
|
129
|
+
{"LOG_LEVEL": s.string().choices("debug", "info", "warn", "error")},
|
|
130
|
+
env_file=None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def test_min_max(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
134
|
+
monkeypatch.setenv("WORKERS", "4")
|
|
135
|
+
result = create_env(
|
|
136
|
+
{"WORKERS": s.integer().min(1).max(16)},
|
|
137
|
+
env_file=None,
|
|
138
|
+
)
|
|
139
|
+
assert result["WORKERS"] == 4
|
|
140
|
+
|
|
141
|
+
def test_pattern(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
142
|
+
monkeypatch.setenv("HOST", "abc123")
|
|
143
|
+
result = create_env(
|
|
144
|
+
{"HOST": s.string().pattern(r"^[a-z0-9]+$")},
|
|
145
|
+
env_file=None,
|
|
146
|
+
)
|
|
147
|
+
assert result["HOST"] == "abc123"
|
|
148
|
+
|
|
149
|
+
def test_pattern_invalid(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
150
|
+
monkeypatch.setenv("HOST", "ABC!")
|
|
151
|
+
with pytest.raises(EnvError, match="must match pattern"):
|
|
152
|
+
create_env(
|
|
153
|
+
{"HOST": s.string().pattern(r"^[a-z0-9]+$")},
|
|
154
|
+
env_file=None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_coerce_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
158
|
+
monkeypatch.setenv("PORT", "abc")
|
|
159
|
+
with pytest.raises(EnvError, match="cannot convert"):
|
|
160
|
+
create_env({"PORT": s.integer()}, env_file=None)
|
|
161
|
+
|
|
162
|
+
def test_multiple_errors(self) -> None:
|
|
163
|
+
with pytest.raises(EnvError) as exc_info:
|
|
164
|
+
create_env(
|
|
165
|
+
{"PORT": s.port(), "DB_URL": s.url()},
|
|
166
|
+
env_file=None,
|
|
167
|
+
)
|
|
168
|
+
assert len(exc_info.value.errors) == 2
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestDotEnv:
|
|
172
|
+
def test_load_dotenv(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
173
|
+
env_file = tmp_path / ".env"
|
|
174
|
+
env_file.write_text('PORT=9090\nAPP_NAME="my app"\n')
|
|
175
|
+
monkeypatch.delenv("PORT", raising=False)
|
|
176
|
+
monkeypatch.delenv("APP_NAME", raising=False)
|
|
177
|
+
load_dotenv(env_file)
|
|
178
|
+
assert os.environ.get("PORT") == "9090"
|
|
179
|
+
assert os.environ.get("APP_NAME") == "my app"
|
|
180
|
+
|
|
181
|
+
def test_dotenv_with_comments(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
182
|
+
env_file = tmp_path / ".env"
|
|
183
|
+
env_file.write_text("# comment\nPORT=8080\n\n")
|
|
184
|
+
monkeypatch.delenv("PORT", raising=False)
|
|
185
|
+
load_dotenv(env_file)
|
|
186
|
+
assert os.environ.get("PORT") == "8080"
|
|
187
|
+
|
|
188
|
+
def test_dotenv_single_quotes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
189
|
+
env_file = tmp_path / ".env"
|
|
190
|
+
env_file.write_text("APP_NAME='my app'\n")
|
|
191
|
+
monkeypatch.delenv("APP_NAME", raising=False)
|
|
192
|
+
load_dotenv(env_file)
|
|
193
|
+
assert os.environ.get("APP_NAME") == "my app"
|
|
194
|
+
|
|
195
|
+
def test_dotenv_missing_file(self) -> None:
|
|
196
|
+
load_dotenv("/nonexistent/.env") # Should not raise
|
|
197
|
+
|
|
198
|
+
def test_dotenv_no_equals(self, tmp_path: Path) -> None:
|
|
199
|
+
env_file = tmp_path / ".env"
|
|
200
|
+
env_file.write_text("NOPE\n")
|
|
201
|
+
load_dotenv(env_file) # Should skip lines without =
|
|
202
|
+
|
|
203
|
+
def test_dotenv_does_not_override(
|
|
204
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
205
|
+
) -> None:
|
|
206
|
+
env_file = tmp_path / ".env"
|
|
207
|
+
env_file.write_text("PORT=9090\n")
|
|
208
|
+
monkeypatch.setenv("PORT", "3000")
|
|
209
|
+
load_dotenv(env_file)
|
|
210
|
+
assert os.environ.get("PORT") == "3000"
|
|
211
|
+
|
|
212
|
+
def test_create_env_with_dotenv(
|
|
213
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
214
|
+
) -> None:
|
|
215
|
+
env_file = tmp_path / ".env"
|
|
216
|
+
env_file.write_text("PORT=4000\n")
|
|
217
|
+
monkeypatch.delenv("PORT", raising=False)
|
|
218
|
+
result = create_env({"PORT": s.port()}, env_file=env_file)
|
|
219
|
+
assert result["PORT"] == 4000
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TestEnvError:
|
|
223
|
+
def test_error_message(self) -> None:
|
|
224
|
+
err = EnvError(["PORT: required", "DB_URL: required"])
|
|
225
|
+
assert "PORT: required" in str(err)
|
|
226
|
+
assert "DB_URL: required" in str(err)
|
|
227
|
+
assert len(err.errors) == 2
|