envcaster 0.1.0__py3-none-any.whl
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/__init__.py +29 -0
- envcaster/core.py +210 -0
- envcaster/dotenv.py +67 -0
- envcaster/py.typed +0 -0
- envcaster-0.1.0.dist-info/METADATA +207 -0
- envcaster-0.1.0.dist-info/RECORD +9 -0
- envcaster-0.1.0.dist-info/WHEEL +5 -0
- envcaster-0.1.0.dist-info/licenses/LICENSE +21 -0
- envcaster-0.1.0.dist-info/top_level.txt +1 -0
envcaster/__init__.py
ADDED
|
@@ -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
|
+
]
|
envcaster/core.py
ADDED
|
@@ -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()
|
envcaster/dotenv.py
ADDED
|
@@ -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
|
envcaster/py.typed
ADDED
|
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,9 @@
|
|
|
1
|
+
envcaster/__init__.py,sha256=L2yif83QketIcGkbhtAr7s2DC4kGLvttCDWCeH_oqVo,875
|
|
2
|
+
envcaster/core.py,sha256=gyL46ehOyABaa86suU_yLeIfflosOSBxNGwlYe13D9s,7168
|
|
3
|
+
envcaster/dotenv.py,sha256=IvFRxXRkx7e9-DIg87n9KF1nlIlMBuloX24-U6o9hIA,2173
|
|
4
|
+
envcaster/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
envcaster-0.1.0.dist-info/licenses/LICENSE,sha256=I6OFRouQilM7XAjOq3RJp8glXpwdnQZZBsrJ5L6Ctso,1071
|
|
6
|
+
envcaster-0.1.0.dist-info/METADATA,sha256=bDAUzE8gDkb2nYvikLQ121U3Ixoam-_-2CeNm6PjZSI,7282
|
|
7
|
+
envcaster-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
envcaster-0.1.0.dist-info/top_level.txt,sha256=0UJS2e59c1eyhYh2jJzyl2fmmR22GHvpJrArXPD64nI,10
|
|
9
|
+
envcaster-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
envcaster
|