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.
@@ -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,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+
@@ -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,5 @@
1
+ """crashbytes-envguard — Lightweight environment variable validation."""
2
+
3
+ from crashbytes_envguard._core import EnvError, SchemaField, create_env, load_dotenv, s
4
+
5
+ __all__ = ["EnvError", "SchemaField", "create_env", "load_dotenv", "s"]
@@ -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
@@ -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