envcaster 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.
- envcaster-0.1.0/LICENSE +21 -0
- envcaster-0.1.0/PKG-INFO +207 -0
- envcaster-0.1.0/README.md +176 -0
- envcaster-0.1.0/pyproject.toml +47 -0
- envcaster-0.1.0/setup.cfg +4 -0
- envcaster-0.1.0/src/envcaster/__init__.py +29 -0
- envcaster-0.1.0/src/envcaster/core.py +210 -0
- envcaster-0.1.0/src/envcaster/dotenv.py +67 -0
- envcaster-0.1.0/src/envcaster/py.typed +0 -0
- envcaster-0.1.0/src/envcaster.egg-info/PKG-INFO +207 -0
- envcaster-0.1.0/src/envcaster.egg-info/SOURCES.txt +14 -0
- envcaster-0.1.0/src/envcaster.egg-info/dependency_links.txt +1 -0
- envcaster-0.1.0/src/envcaster.egg-info/requires.txt +6 -0
- envcaster-0.1.0/src/envcaster.egg-info/top_level.txt +1 -0
- envcaster-0.1.0/tests/test_core.py +151 -0
- envcaster-0.1.0/tests/test_dotenv.py +57 -0
envcaster-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YoungAlpaccino
|
|
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.
|
envcaster-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envcaster
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors.
|
|
5
|
+
Author: YoungAlpaccino
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YoungAlpaccino/envcast
|
|
8
|
+
Project-URL: Repository, https://github.com/YoungAlpaccino/envcast
|
|
9
|
+
Project-URL: Issues, https://github.com/YoungAlpaccino/envcast/issues
|
|
10
|
+
Keywords: env,environment,config,dotenv,settings,12factor,typed,configuration
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Requires-Dist: build; extra == "dev"
|
|
29
|
+
Requires-Dist: twine; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# envcaster ⚙️
|
|
33
|
+
|
|
34
|
+
> Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
|
|
35
|
+
|
|
36
|
+
[](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/envcaster/)
|
|
38
|
+
[](https://pypi.org/project/envcaster/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
`os.environ` only ever gives you strings. So every project grows the same little
|
|
42
|
+
pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
|
|
43
|
+
quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
|
|
44
|
+
|
|
45
|
+
- 🪶 **Zero required dependencies** — pure standard library.
|
|
46
|
+
- 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
|
|
47
|
+
- 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
|
|
48
|
+
- 🧪 **Fully tested** across Python 3.9–3.12.
|
|
49
|
+
- 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install envcaster
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from envcaster import env
|
|
65
|
+
|
|
66
|
+
PORT = env.int("PORT", default=8000)
|
|
67
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
68
|
+
HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
|
|
69
|
+
TIMEOUT = env.float("TIMEOUT", default=1.5)
|
|
70
|
+
SECRET = env.str("SECRET_KEY", required=True) # raises if not set
|
|
71
|
+
DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
|
|
72
|
+
FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **A variable is required unless you give it a `default`.** Missing required
|
|
76
|
+
> variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
|
|
77
|
+
> `EnvError` — catch broadly or narrowly.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
### Booleans that actually behave
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
|
|
87
|
+
# 0 false f no n off -> False
|
|
88
|
+
# anything else -> CastError
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
No more `bool("False") == True` bugs.
|
|
92
|
+
|
|
93
|
+
### Lists (and lists of other types)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
|
|
97
|
+
env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
|
|
98
|
+
env.list("TAGS", default=[]) # missing -> []
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### JSON and paths
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
|
|
105
|
+
env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Anything else — bring your own cast
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from decimal import Decimal
|
|
112
|
+
|
|
113
|
+
env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
|
|
114
|
+
env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
|
|
115
|
+
# exceptions from your function are wrapped in CastError, naming the variable
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Scoped readers with a prefix
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from envcaster import Env
|
|
122
|
+
|
|
123
|
+
app = Env(prefix="APP_")
|
|
124
|
+
app.int("PORT") # reads APP_PORT
|
|
125
|
+
app.bool("DEBUG") # reads APP_DEBUG
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Read from somewhere other than `os.environ`
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
|
|
132
|
+
cfg.int("PORT") # 9000
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Load a `.env` file (no dependency)
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from envcaster import load_dotenv, env
|
|
139
|
+
|
|
140
|
+
load_dotenv() # reads ./.env into os.environ (won't override real env vars)
|
|
141
|
+
load_dotenv(".env.local", override=True)
|
|
142
|
+
|
|
143
|
+
PORT = env.int("PORT")
|
|
144
|
+
|
|
145
|
+
# Or parse without touching the environment:
|
|
146
|
+
from envcaster import read_dotenv
|
|
147
|
+
values = read_dotenv(".env") # -> {"PORT": "8000", ...}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
|
|
151
|
+
interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## API reference
|
|
156
|
+
|
|
157
|
+
| Call | Returns | Notes |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
|
|
160
|
+
| `env.int(name, …)` | `int` | Base-10, whitespace stripped |
|
|
161
|
+
| `env.float(name, …)` | `float` | |
|
|
162
|
+
| `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
|
|
163
|
+
| `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
|
|
164
|
+
| `env.json(name, …)` | `Any` | `json.loads` of the value |
|
|
165
|
+
| `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
|
|
166
|
+
| `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
|
|
167
|
+
| `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
|
|
168
|
+
| `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
|
|
169
|
+
| `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
|
|
170
|
+
|
|
171
|
+
**Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Why not just `os.environ`?
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
# Before
|
|
179
|
+
import os
|
|
180
|
+
PORT = int(os.environ.get("PORT", "8000"))
|
|
181
|
+
DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
|
182
|
+
HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
|
|
183
|
+
|
|
184
|
+
# After
|
|
185
|
+
from envcaster import env
|
|
186
|
+
PORT = env.int("PORT", default=8000)
|
|
187
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
188
|
+
HOSTS = env.list("ALLOWED_HOSTS", default=[])
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git clone https://github.com/YoungAlpaccino/envcast
|
|
197
|
+
cd envcast
|
|
198
|
+
pip install -e ".[dev]"
|
|
199
|
+
pytest # run tests
|
|
200
|
+
ruff check . # lint
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# envcaster ⚙️
|
|
2
|
+
|
|
3
|
+
> Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/envcaster/)
|
|
7
|
+
[](https://pypi.org/project/envcaster/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
`os.environ` only ever gives you strings. So every project grows the same little
|
|
11
|
+
pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
|
|
12
|
+
quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
|
|
13
|
+
|
|
14
|
+
- 🪶 **Zero required dependencies** — pure standard library.
|
|
15
|
+
- 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
|
|
16
|
+
- 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
|
|
17
|
+
- 🧪 **Fully tested** across Python 3.9–3.12.
|
|
18
|
+
- 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install envcaster
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from envcaster import env
|
|
34
|
+
|
|
35
|
+
PORT = env.int("PORT", default=8000)
|
|
36
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
37
|
+
HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
|
|
38
|
+
TIMEOUT = env.float("TIMEOUT", default=1.5)
|
|
39
|
+
SECRET = env.str("SECRET_KEY", required=True) # raises if not set
|
|
40
|
+
DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
|
|
41
|
+
FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **A variable is required unless you give it a `default`.** Missing required
|
|
45
|
+
> variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
|
|
46
|
+
> `EnvError` — catch broadly or narrowly.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Booleans that actually behave
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
|
|
56
|
+
# 0 false f no n off -> False
|
|
57
|
+
# anything else -> CastError
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
No more `bool("False") == True` bugs.
|
|
61
|
+
|
|
62
|
+
### Lists (and lists of other types)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
|
|
66
|
+
env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
|
|
67
|
+
env.list("TAGS", default=[]) # missing -> []
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### JSON and paths
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
|
|
74
|
+
env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Anything else — bring your own cast
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from decimal import Decimal
|
|
81
|
+
|
|
82
|
+
env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
|
|
83
|
+
env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
|
|
84
|
+
# exceptions from your function are wrapped in CastError, naming the variable
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Scoped readers with a prefix
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from envcaster import Env
|
|
91
|
+
|
|
92
|
+
app = Env(prefix="APP_")
|
|
93
|
+
app.int("PORT") # reads APP_PORT
|
|
94
|
+
app.bool("DEBUG") # reads APP_DEBUG
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Read from somewhere other than `os.environ`
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
|
|
101
|
+
cfg.int("PORT") # 9000
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Load a `.env` file (no dependency)
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from envcaster import load_dotenv, env
|
|
108
|
+
|
|
109
|
+
load_dotenv() # reads ./.env into os.environ (won't override real env vars)
|
|
110
|
+
load_dotenv(".env.local", override=True)
|
|
111
|
+
|
|
112
|
+
PORT = env.int("PORT")
|
|
113
|
+
|
|
114
|
+
# Or parse without touching the environment:
|
|
115
|
+
from envcaster import read_dotenv
|
|
116
|
+
values = read_dotenv(".env") # -> {"PORT": "8000", ...}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
|
|
120
|
+
interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## API reference
|
|
125
|
+
|
|
126
|
+
| Call | Returns | Notes |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
|
|
129
|
+
| `env.int(name, …)` | `int` | Base-10, whitespace stripped |
|
|
130
|
+
| `env.float(name, …)` | `float` | |
|
|
131
|
+
| `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
|
|
132
|
+
| `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
|
|
133
|
+
| `env.json(name, …)` | `Any` | `json.loads` of the value |
|
|
134
|
+
| `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
|
|
135
|
+
| `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
|
|
136
|
+
| `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
|
|
137
|
+
| `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
|
|
138
|
+
| `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
|
|
139
|
+
|
|
140
|
+
**Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Why not just `os.environ`?
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
# Before
|
|
148
|
+
import os
|
|
149
|
+
PORT = int(os.environ.get("PORT", "8000"))
|
|
150
|
+
DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
|
151
|
+
HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
|
|
152
|
+
|
|
153
|
+
# After
|
|
154
|
+
from envcaster import env
|
|
155
|
+
PORT = env.int("PORT", default=8000)
|
|
156
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
157
|
+
HOSTS = env.list("ALLOWED_HOSTS", default=[])
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
git clone https://github.com/YoungAlpaccino/envcast
|
|
166
|
+
cd envcast
|
|
167
|
+
pip install -e ".[dev]"
|
|
168
|
+
pytest # run tests
|
|
169
|
+
ruff check . # lint
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "envcaster"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "YoungAlpaccino" }]
|
|
13
|
+
keywords = ["env", "environment", "config", "dotenv", "settings", "12factor", "typed", "configuration"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7", "ruff", "build", "twine"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/YoungAlpaccino/envcast"
|
|
34
|
+
Repository = "https://github.com/YoungAlpaccino/envcast"
|
|
35
|
+
Issues = "https://github.com/YoungAlpaccino/envcast/issues"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
envcaster = ["py.typed"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""envcaster — typed, dependency-free environment variable loading.
|
|
2
|
+
|
|
3
|
+
Read environment variables as the type you actually want — ``int``, ``float``,
|
|
4
|
+
``bool``, ``list``, ``json``, ``Path`` — with defaults, required-checks, and
|
|
5
|
+
error messages that tell you exactly which variable was wrong and why. Plus a
|
|
6
|
+
tiny ``.env`` loader. Zero dependencies, pure standard library.
|
|
7
|
+
|
|
8
|
+
from envcaster import env
|
|
9
|
+
|
|
10
|
+
PORT = env.int("PORT", default=8000)
|
|
11
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
12
|
+
HOSTS = env.list("ALLOWED_HOSTS", sep=",")
|
|
13
|
+
SECRET = env.str("SECRET_KEY", required=True)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from envcaster.core import CastError, Env, EnvError, MissingEnvError, env
|
|
17
|
+
from envcaster.dotenv import load_dotenv, read_dotenv
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"env",
|
|
23
|
+
"Env",
|
|
24
|
+
"EnvError",
|
|
25
|
+
"MissingEnvError",
|
|
26
|
+
"CastError",
|
|
27
|
+
"load_dotenv",
|
|
28
|
+
"read_dotenv",
|
|
29
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Typed reads from the environment with defaults, requirements, and clear errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import json as _json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, List, Mapping, Optional, TypeVar
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Env",
|
|
13
|
+
"EnvError",
|
|
14
|
+
"MissingEnvError",
|
|
15
|
+
"CastError",
|
|
16
|
+
"env",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EnvError(Exception):
|
|
23
|
+
"""Base class for all envcaster errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MissingEnvError(EnvError, KeyError):
|
|
27
|
+
"""A required environment variable was not set."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: str) -> None:
|
|
30
|
+
self.name = name
|
|
31
|
+
super().__init__(f"Required environment variable {name!r} is not set.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CastError(EnvError, ValueError):
|
|
35
|
+
"""An environment variable could not be cast to the requested type."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str, value: str, type_name: str, hint: str = "") -> None:
|
|
38
|
+
self.name = name
|
|
39
|
+
self.value = value
|
|
40
|
+
self.type_name = type_name
|
|
41
|
+
msg = f"Environment variable {name!r}={value!r} is not a valid {type_name}."
|
|
42
|
+
if hint:
|
|
43
|
+
msg += f" {hint}"
|
|
44
|
+
super().__init__(msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Sentinels — distinct from any user value (including None).
|
|
48
|
+
class _Unset:
|
|
49
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
50
|
+
return "<unset>"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_UNSET: Any = _Unset()
|
|
54
|
+
_USE_DEFAULT: Any = object()
|
|
55
|
+
|
|
56
|
+
_TRUE = {"1", "true", "t", "yes", "y", "on"}
|
|
57
|
+
_FALSE = {"0", "false", "f", "no", "n", "off"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Env:
|
|
61
|
+
"""A typed reader over a mapping of environment variables.
|
|
62
|
+
|
|
63
|
+
By default it reads the live process environment (``os.environ``). Pass a
|
|
64
|
+
``source`` mapping (e.g. a plain ``dict``) to read from somewhere else —
|
|
65
|
+
handy in tests. A ``prefix`` is prepended to every name you look up, so
|
|
66
|
+
``Env(prefix="APP_").int("PORT")`` reads ``APP_PORT``.
|
|
67
|
+
|
|
68
|
+
A variable is **required unless you pass a ``default``**. Missing required
|
|
69
|
+
variables raise :class:`MissingEnvError`; bad values raise :class:`CastError`.
|
|
70
|
+
Both subclass :class:`EnvError` (and the matching builtin), so you can catch
|
|
71
|
+
broadly or narrowly.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
source: Optional[Mapping[str, str]] = None,
|
|
77
|
+
*,
|
|
78
|
+
prefix: str = "",
|
|
79
|
+
) -> None:
|
|
80
|
+
self._source = source
|
|
81
|
+
self._prefix = prefix
|
|
82
|
+
|
|
83
|
+
# -- internals ---------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _mapping(self) -> Mapping[str, str]:
|
|
86
|
+
# Read os.environ lazily so changes after construction are seen.
|
|
87
|
+
return os.environ if self._source is None else self._source
|
|
88
|
+
|
|
89
|
+
def _raw(self, name: str, default: Any, required: bool) -> str:
|
|
90
|
+
key = self._prefix + name
|
|
91
|
+
value = self._mapping().get(key)
|
|
92
|
+
if value is None:
|
|
93
|
+
if required or default is _UNSET:
|
|
94
|
+
raise MissingEnvError(key)
|
|
95
|
+
return _USE_DEFAULT
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
# -- typed getters -----------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def str(self, name: str, default: Any = _UNSET, *, required: bool = False) -> str:
|
|
101
|
+
"""Return the variable as a string (the raw value, unchanged)."""
|
|
102
|
+
raw = self._raw(name, default, required)
|
|
103
|
+
return default if raw is _USE_DEFAULT else raw
|
|
104
|
+
|
|
105
|
+
def int(self, name: str, default: Any = _UNSET, *, required: bool = False) -> int:
|
|
106
|
+
"""Return the variable parsed as an ``int`` (base-10, whitespace ignored)."""
|
|
107
|
+
raw = self._raw(name, default, required)
|
|
108
|
+
if raw is _USE_DEFAULT:
|
|
109
|
+
return default
|
|
110
|
+
try:
|
|
111
|
+
return int(raw.strip())
|
|
112
|
+
except ValueError:
|
|
113
|
+
raise CastError(self._prefix + name, raw, "integer") from None
|
|
114
|
+
|
|
115
|
+
def float(self, name: str, default: Any = _UNSET, *, required: bool = False) -> float:
|
|
116
|
+
"""Return the variable parsed as a ``float``."""
|
|
117
|
+
raw = self._raw(name, default, required)
|
|
118
|
+
if raw is _USE_DEFAULT:
|
|
119
|
+
return default
|
|
120
|
+
try:
|
|
121
|
+
return float(raw.strip())
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise CastError(self._prefix + name, raw, "float") from None
|
|
124
|
+
|
|
125
|
+
def bool(self, name: str, default: Any = _UNSET, *, required: bool = False) -> bool:
|
|
126
|
+
"""Return the variable as a ``bool``.
|
|
127
|
+
|
|
128
|
+
Truthy: ``1 true t yes y on``. Falsy: ``0 false f no n off``
|
|
129
|
+
(case-insensitive). Anything else raises :class:`CastError`.
|
|
130
|
+
"""
|
|
131
|
+
raw = self._raw(name, default, required)
|
|
132
|
+
if raw is _USE_DEFAULT:
|
|
133
|
+
return default
|
|
134
|
+
token = raw.strip().lower()
|
|
135
|
+
if token in _TRUE:
|
|
136
|
+
return True
|
|
137
|
+
if token in _FALSE:
|
|
138
|
+
return False
|
|
139
|
+
raise CastError(
|
|
140
|
+
self._prefix + name,
|
|
141
|
+
raw,
|
|
142
|
+
"boolean",
|
|
143
|
+
hint=f"Use one of: {', '.join(sorted(_TRUE | _FALSE))}.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def list(
|
|
147
|
+
self,
|
|
148
|
+
name: str,
|
|
149
|
+
default: Any = _UNSET,
|
|
150
|
+
*,
|
|
151
|
+
sep: str = ",",
|
|
152
|
+
cast: Callable[[str], T] = builtins.str, # type: ignore[assignment]
|
|
153
|
+
required: bool = False,
|
|
154
|
+
) -> List[T]:
|
|
155
|
+
"""Split the variable on ``sep`` into a list.
|
|
156
|
+
|
|
157
|
+
Items are stripped of surrounding whitespace and empty items dropped.
|
|
158
|
+
Pass ``cast`` to convert each item (e.g. ``cast=int``).
|
|
159
|
+
"""
|
|
160
|
+
raw = self._raw(name, default, required)
|
|
161
|
+
if raw is _USE_DEFAULT:
|
|
162
|
+
return default
|
|
163
|
+
items = [piece.strip() for piece in raw.split(sep)]
|
|
164
|
+
items = [piece for piece in items if piece]
|
|
165
|
+
try:
|
|
166
|
+
return [cast(piece) for piece in items]
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
raise CastError(self._prefix + name, raw, "list", hint="An item failed to cast.") from None
|
|
169
|
+
|
|
170
|
+
def json(self, name: str, default: Any = _UNSET, *, required: bool = False) -> Any:
|
|
171
|
+
"""Parse the variable as JSON and return the resulting object."""
|
|
172
|
+
raw = self._raw(name, default, required)
|
|
173
|
+
if raw is _USE_DEFAULT:
|
|
174
|
+
return default
|
|
175
|
+
try:
|
|
176
|
+
return _json.loads(raw)
|
|
177
|
+
except _json.JSONDecodeError:
|
|
178
|
+
raise CastError(self._prefix + name, raw, "JSON") from None
|
|
179
|
+
|
|
180
|
+
def path(self, name: str, default: Any = _UNSET, *, required: bool = False) -> Path:
|
|
181
|
+
"""Return the variable as a :class:`pathlib.Path` (not resolved/validated)."""
|
|
182
|
+
raw = self._raw(name, default, required)
|
|
183
|
+
if raw is _USE_DEFAULT:
|
|
184
|
+
return default
|
|
185
|
+
return Path(raw)
|
|
186
|
+
|
|
187
|
+
def cast(
|
|
188
|
+
self,
|
|
189
|
+
name: str,
|
|
190
|
+
func: Callable[[str], T],
|
|
191
|
+
default: Any = _UNSET,
|
|
192
|
+
*,
|
|
193
|
+
required: bool = False,
|
|
194
|
+
) -> T:
|
|
195
|
+
"""Apply an arbitrary ``func`` to the raw string value.
|
|
196
|
+
|
|
197
|
+
Any exception from ``func`` is wrapped in :class:`CastError`.
|
|
198
|
+
"""
|
|
199
|
+
raw = self._raw(name, default, required)
|
|
200
|
+
if raw is _USE_DEFAULT:
|
|
201
|
+
return default
|
|
202
|
+
try:
|
|
203
|
+
return func(raw)
|
|
204
|
+
except Exception as exc: # noqa: BLE001 - re-raised as CastError
|
|
205
|
+
type_name = getattr(func, "__name__", "value")
|
|
206
|
+
raise CastError(self._prefix + name, raw, type_name, hint=str(exc)) from None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# A ready-to-use instance bound to the live process environment.
|
|
210
|
+
env = Env()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""A tiny, dependency-free ``.env`` reader.
|
|
2
|
+
|
|
3
|
+
Just enough to load a local ``.env`` in development. For complex needs
|
|
4
|
+
(variable interpolation, multiline values) reach for python-dotenv.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Union
|
|
12
|
+
|
|
13
|
+
__all__ = ["read_dotenv", "load_dotenv"]
|
|
14
|
+
|
|
15
|
+
_PathLike = Union[str, "os.PathLike[str]"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _unquote(value: str) -> str:
|
|
19
|
+
value = value.strip()
|
|
20
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
21
|
+
return value[1:-1]
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_dotenv(path: _PathLike = ".env") -> Dict[str, str]:
|
|
26
|
+
"""Parse a ``.env`` file into a dict. Returns ``{}`` if the file is absent.
|
|
27
|
+
|
|
28
|
+
Supports ``KEY=value``, ``export KEY=value``, ``#`` comments, blank lines,
|
|
29
|
+
and single/double-quoted values. Does **not** touch ``os.environ``.
|
|
30
|
+
"""
|
|
31
|
+
file = Path(path)
|
|
32
|
+
if not file.is_file():
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
result: Dict[str, str] = {}
|
|
36
|
+
for raw_line in file.read_text(encoding="utf-8").splitlines():
|
|
37
|
+
line = raw_line.strip()
|
|
38
|
+
if not line or line.startswith("#"):
|
|
39
|
+
continue
|
|
40
|
+
if line.startswith("export "):
|
|
41
|
+
line = line[len("export ") :].lstrip()
|
|
42
|
+
if "=" not in line:
|
|
43
|
+
continue
|
|
44
|
+
key, _, value = line.partition("=")
|
|
45
|
+
key = key.strip()
|
|
46
|
+
if not key:
|
|
47
|
+
continue
|
|
48
|
+
# Strip an inline comment only when the value is not quoted.
|
|
49
|
+
stripped = value.strip()
|
|
50
|
+
if stripped[:1] not in ("'", '"') and " #" in value:
|
|
51
|
+
value = value.split(" #", 1)[0]
|
|
52
|
+
result[key] = _unquote(value)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_dotenv(path: _PathLike = ".env", *, override: bool = False) -> Dict[str, str]:
|
|
57
|
+
"""Read a ``.env`` file and inject its values into ``os.environ``.
|
|
58
|
+
|
|
59
|
+
By default existing environment variables win (``override=False``), so real
|
|
60
|
+
environment configuration is never clobbered by the file. Returns the dict
|
|
61
|
+
that was parsed from the file.
|
|
62
|
+
"""
|
|
63
|
+
values = read_dotenv(path)
|
|
64
|
+
for key, value in values.items():
|
|
65
|
+
if override or key not in os.environ:
|
|
66
|
+
os.environ[key] = value
|
|
67
|
+
return values
|
|
File without changes
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envcaster
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors.
|
|
5
|
+
Author: YoungAlpaccino
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YoungAlpaccino/envcast
|
|
8
|
+
Project-URL: Repository, https://github.com/YoungAlpaccino/envcast
|
|
9
|
+
Project-URL: Issues, https://github.com/YoungAlpaccino/envcast/issues
|
|
10
|
+
Keywords: env,environment,config,dotenv,settings,12factor,typed,configuration
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Requires-Dist: build; extra == "dev"
|
|
29
|
+
Requires-Dist: twine; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# envcaster ⚙️
|
|
33
|
+
|
|
34
|
+
> Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
|
|
35
|
+
|
|
36
|
+
[](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/envcaster/)
|
|
38
|
+
[](https://pypi.org/project/envcaster/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
`os.environ` only ever gives you strings. So every project grows the same little
|
|
42
|
+
pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
|
|
43
|
+
quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
|
|
44
|
+
|
|
45
|
+
- 🪶 **Zero required dependencies** — pure standard library.
|
|
46
|
+
- 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
|
|
47
|
+
- 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
|
|
48
|
+
- 🧪 **Fully tested** across Python 3.9–3.12.
|
|
49
|
+
- 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install envcaster
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from envcaster import env
|
|
65
|
+
|
|
66
|
+
PORT = env.int("PORT", default=8000)
|
|
67
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
68
|
+
HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
|
|
69
|
+
TIMEOUT = env.float("TIMEOUT", default=1.5)
|
|
70
|
+
SECRET = env.str("SECRET_KEY", required=True) # raises if not set
|
|
71
|
+
DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
|
|
72
|
+
FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **A variable is required unless you give it a `default`.** Missing required
|
|
76
|
+
> variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
|
|
77
|
+
> `EnvError` — catch broadly or narrowly.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
### Booleans that actually behave
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
|
|
87
|
+
# 0 false f no n off -> False
|
|
88
|
+
# anything else -> CastError
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
No more `bool("False") == True` bugs.
|
|
92
|
+
|
|
93
|
+
### Lists (and lists of other types)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
|
|
97
|
+
env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
|
|
98
|
+
env.list("TAGS", default=[]) # missing -> []
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### JSON and paths
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
|
|
105
|
+
env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Anything else — bring your own cast
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from decimal import Decimal
|
|
112
|
+
|
|
113
|
+
env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
|
|
114
|
+
env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
|
|
115
|
+
# exceptions from your function are wrapped in CastError, naming the variable
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Scoped readers with a prefix
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from envcaster import Env
|
|
122
|
+
|
|
123
|
+
app = Env(prefix="APP_")
|
|
124
|
+
app.int("PORT") # reads APP_PORT
|
|
125
|
+
app.bool("DEBUG") # reads APP_DEBUG
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Read from somewhere other than `os.environ`
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
|
|
132
|
+
cfg.int("PORT") # 9000
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Load a `.env` file (no dependency)
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from envcaster import load_dotenv, env
|
|
139
|
+
|
|
140
|
+
load_dotenv() # reads ./.env into os.environ (won't override real env vars)
|
|
141
|
+
load_dotenv(".env.local", override=True)
|
|
142
|
+
|
|
143
|
+
PORT = env.int("PORT")
|
|
144
|
+
|
|
145
|
+
# Or parse without touching the environment:
|
|
146
|
+
from envcaster import read_dotenv
|
|
147
|
+
values = read_dotenv(".env") # -> {"PORT": "8000", ...}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
|
|
151
|
+
interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## API reference
|
|
156
|
+
|
|
157
|
+
| Call | Returns | Notes |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
|
|
160
|
+
| `env.int(name, …)` | `int` | Base-10, whitespace stripped |
|
|
161
|
+
| `env.float(name, …)` | `float` | |
|
|
162
|
+
| `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
|
|
163
|
+
| `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
|
|
164
|
+
| `env.json(name, …)` | `Any` | `json.loads` of the value |
|
|
165
|
+
| `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
|
|
166
|
+
| `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
|
|
167
|
+
| `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
|
|
168
|
+
| `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
|
|
169
|
+
| `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
|
|
170
|
+
|
|
171
|
+
**Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Why not just `os.environ`?
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
# Before
|
|
179
|
+
import os
|
|
180
|
+
PORT = int(os.environ.get("PORT", "8000"))
|
|
181
|
+
DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
|
182
|
+
HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
|
|
183
|
+
|
|
184
|
+
# After
|
|
185
|
+
from envcaster import env
|
|
186
|
+
PORT = env.int("PORT", default=8000)
|
|
187
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
188
|
+
HOSTS = env.list("ALLOWED_HOSTS", default=[])
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git clone https://github.com/YoungAlpaccino/envcast
|
|
197
|
+
cd envcast
|
|
198
|
+
pip install -e ".[dev]"
|
|
199
|
+
pytest # run tests
|
|
200
|
+
ruff check . # lint
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/envcaster/__init__.py
|
|
5
|
+
src/envcaster/core.py
|
|
6
|
+
src/envcaster/dotenv.py
|
|
7
|
+
src/envcaster/py.typed
|
|
8
|
+
src/envcaster.egg-info/PKG-INFO
|
|
9
|
+
src/envcaster.egg-info/SOURCES.txt
|
|
10
|
+
src/envcaster.egg-info/dependency_links.txt
|
|
11
|
+
src/envcaster.egg-info/requires.txt
|
|
12
|
+
src/envcaster.egg-info/top_level.txt
|
|
13
|
+
tests/test_core.py
|
|
14
|
+
tests/test_dotenv.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
envcaster
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from envcaster import CastError, Env, MissingEnvError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make(**values):
|
|
9
|
+
return Env(source=dict(values))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# -- str ------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_str_present_and_default():
|
|
16
|
+
e = make(NAME="alice")
|
|
17
|
+
assert e.str("NAME") == "alice"
|
|
18
|
+
assert e.str("MISSING", default="fallback") == "fallback"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_empty_string_is_a_value_not_missing():
|
|
22
|
+
e = make(NAME="")
|
|
23
|
+
assert e.str("NAME") == ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# -- int / float ----------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_int_parses_and_strips_whitespace():
|
|
30
|
+
assert make(PORT=" 8000 ").int("PORT") == 8000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_int_bad_value_raises_casterror_with_name():
|
|
34
|
+
with pytest.raises(CastError) as exc:
|
|
35
|
+
make(PORT="abc").int("PORT")
|
|
36
|
+
assert exc.value.name == "PORT"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_float_parses():
|
|
40
|
+
assert make(RATE="1.5").float("RATE") == 1.5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# -- bool -----------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "t", "yes", "Y", "on"])
|
|
47
|
+
def test_bool_truthy(raw):
|
|
48
|
+
assert make(FLAG=raw).bool("FLAG") is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("raw", ["0", "false", "F", "no", "n", "OFF"])
|
|
52
|
+
def test_bool_falsy(raw):
|
|
53
|
+
assert make(FLAG=raw).bool("FLAG") is False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_bool_invalid_raises():
|
|
57
|
+
with pytest.raises(CastError):
|
|
58
|
+
make(FLAG="maybe").bool("FLAG")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_bool_default():
|
|
62
|
+
assert make().bool("FLAG", default=False) is False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -- list -----------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_list_splits_strips_and_drops_empty():
|
|
69
|
+
e = make(HOSTS="a, b ,, c")
|
|
70
|
+
assert e.list("HOSTS") == ["a", "b", "c"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_list_custom_sep_and_cast():
|
|
74
|
+
e = make(NUMS="1|2|3")
|
|
75
|
+
assert e.list("NUMS", sep="|", cast=int) == [1, 2, 3]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_list_cast_failure_raises():
|
|
79
|
+
with pytest.raises(CastError):
|
|
80
|
+
make(NUMS="1,x,3").list("NUMS", cast=int)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_list_default():
|
|
84
|
+
assert make().list("HOSTS", default=[]) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -- json -----------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_json_parses_object():
|
|
91
|
+
e = make(CONF=json.dumps({"a": 1, "b": [2, 3]}))
|
|
92
|
+
assert e.json("CONF") == {"a": 1, "b": [2, 3]}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_json_invalid_raises():
|
|
96
|
+
with pytest.raises(CastError):
|
|
97
|
+
make(CONF="{not json}").json("CONF")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# -- path -----------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_path_returns_pathlib():
|
|
104
|
+
from pathlib import Path
|
|
105
|
+
|
|
106
|
+
assert make(DIR="/tmp/x").path("DIR") == Path("/tmp/x")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# -- cast -----------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_custom_cast():
|
|
113
|
+
e = make(COLOR="ff0000")
|
|
114
|
+
assert e.cast("COLOR", lambda v: int(v, 16)) == 0xFF0000
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_custom_cast_wraps_errors():
|
|
118
|
+
with pytest.raises(CastError):
|
|
119
|
+
make(COLOR="zzz").cast("COLOR", lambda v: int(v, 16))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# -- required / missing ---------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_missing_without_default_raises():
|
|
126
|
+
with pytest.raises(MissingEnvError):
|
|
127
|
+
make().str("SECRET")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_required_true_raises_even_with_default():
|
|
131
|
+
with pytest.raises(MissingEnvError):
|
|
132
|
+
make().str("SECRET", default="x", required=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_missing_error_is_keyerror_subclass():
|
|
136
|
+
assert issubclass(MissingEnvError, KeyError)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# -- prefix & live os.environ --------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_prefix():
|
|
143
|
+
e = Env(source={"APP_PORT": "9000"}, prefix="APP_")
|
|
144
|
+
assert e.int("PORT") == 9000
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_default_env_reads_live_os_environ(monkeypatch):
|
|
148
|
+
from envcaster import env
|
|
149
|
+
|
|
150
|
+
monkeypatch.setenv("ENVCAST_TEST_X", "42")
|
|
151
|
+
assert env.int("ENVCAST_TEST_X") == 42
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from envcaster import load_dotenv, read_dotenv
|
|
2
|
+
|
|
3
|
+
SAMPLE = """\
|
|
4
|
+
# a comment
|
|
5
|
+
NAME=alice
|
|
6
|
+
export TOKEN=secret
|
|
7
|
+
QUOTED="hello world"
|
|
8
|
+
SINGLE='single quoted'
|
|
9
|
+
WITH_COMMENT=value # trailing comment
|
|
10
|
+
EMPTY=
|
|
11
|
+
|
|
12
|
+
# indented comment
|
|
13
|
+
PORT=8000
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write(tmp_path, text=SAMPLE):
|
|
18
|
+
f = tmp_path / ".env"
|
|
19
|
+
f.write_text(text, encoding="utf-8")
|
|
20
|
+
return f
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_read_dotenv_parses_all_forms(tmp_path):
|
|
24
|
+
data = read_dotenv(write(tmp_path))
|
|
25
|
+
assert data["NAME"] == "alice"
|
|
26
|
+
assert data["TOKEN"] == "secret" # export prefix stripped
|
|
27
|
+
assert data["QUOTED"] == "hello world" # double quotes stripped
|
|
28
|
+
assert data["SINGLE"] == "single quoted" # single quotes stripped
|
|
29
|
+
assert data["WITH_COMMENT"] == "value" # inline comment dropped
|
|
30
|
+
assert data["EMPTY"] == ""
|
|
31
|
+
assert data["PORT"] == "8000"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_read_dotenv_missing_file_returns_empty(tmp_path):
|
|
35
|
+
assert read_dotenv(tmp_path / "nope.env") == {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_quoted_value_keeps_hash(tmp_path):
|
|
39
|
+
f = write(tmp_path, 'URL="http://x/#frag"\n')
|
|
40
|
+
assert read_dotenv(f)["URL"] == "http://x/#frag"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_load_dotenv_does_not_override_by_default(tmp_path, monkeypatch):
|
|
44
|
+
monkeypatch.setenv("NAME", "real")
|
|
45
|
+
load_dotenv(write(tmp_path))
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
assert os.environ["NAME"] == "real" # existing env wins
|
|
49
|
+
assert os.environ["TOKEN"] == "secret" # new key injected
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_load_dotenv_override(tmp_path, monkeypatch):
|
|
53
|
+
monkeypatch.setenv("NAME", "real")
|
|
54
|
+
load_dotenv(write(tmp_path), override=True)
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
assert os.environ["NAME"] == "alice"
|