tidyenv 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.
- tidyenv/__init__.py +13 -0
- tidyenv/_dotenv.py +59 -0
- tidyenv/_env.py +322 -0
- tidyenv/py.typed +0 -0
- tidyenv-0.1.0.dist-info/METADATA +156 -0
- tidyenv-0.1.0.dist-info/RECORD +8 -0
- tidyenv-0.1.0.dist-info/WHEEL +4 -0
- tidyenv-0.1.0.dist-info/licenses/LICENSE +21 -0
tidyenv/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Typed environment variables with friendly errors and built-in .env support."""
|
|
2
|
+
|
|
3
|
+
from tidyenv._dotenv import parse_dotenv
|
|
4
|
+
from tidyenv._env import Env, EnvError, Problem
|
|
5
|
+
|
|
6
|
+
__all__ = ["Env", "EnvError", "Problem", "env", "parse_dotenv"]
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
# Show errors as tidyenv.EnvError, not tidyenv._env.EnvError.
|
|
10
|
+
for _cls in (Env, EnvError, Problem):
|
|
11
|
+
_cls.__module__ = __name__
|
|
12
|
+
|
|
13
|
+
env = Env()
|
tidyenv/_dotenv.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# The value keeps its leading whitespace: `A= # note` is an empty value plus a comment.
|
|
6
|
+
_LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=(.*?)\s*$")
|
|
7
|
+
_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_dotenv(text: str) -> dict[str, str]:
|
|
11
|
+
"""Parse the contents of a ``.env`` file into a dict.
|
|
12
|
+
|
|
13
|
+
Supports ``KEY=value``, ``export KEY=value``, ``#`` comments, blank lines,
|
|
14
|
+
'single quotes' (taken literally) and "double quotes" (with ``\\n``, ``\\t``,
|
|
15
|
+
``\\"`` and ``\\\\`` escapes). Values must fit on one line.
|
|
16
|
+
Raises ``ValueError`` naming the bad line.
|
|
17
|
+
"""
|
|
18
|
+
values: dict[str, str] = {}
|
|
19
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
20
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
21
|
+
continue
|
|
22
|
+
match = _LINE.match(line)
|
|
23
|
+
if match is None:
|
|
24
|
+
raise ValueError(f"line {lineno}: expected KEY=VALUE")
|
|
25
|
+
key, raw = match.groups()
|
|
26
|
+
try:
|
|
27
|
+
values[key] = _value(raw)
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
raise ValueError(f"line {lineno}: {exc}") from None
|
|
30
|
+
return values
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _value(raw: str) -> str:
|
|
34
|
+
value = raw.lstrip()
|
|
35
|
+
if value[:1] in ("'", '"'):
|
|
36
|
+
quote = value[0]
|
|
37
|
+
end = _closing_quote(value, quote)
|
|
38
|
+
rest = value[end + 1 :].strip()
|
|
39
|
+
if rest and not rest.startswith("#"):
|
|
40
|
+
raise ValueError(f"unexpected text after closing quote: {rest!r}")
|
|
41
|
+
body = value[1:end]
|
|
42
|
+
if quote == "'":
|
|
43
|
+
return body
|
|
44
|
+
return re.sub(r"\\(.)", lambda m: _ESCAPES.get(m.group(1), m.group(0)), body)
|
|
45
|
+
# Unquoted: an inline comment needs whitespace before the '#'; the space after
|
|
46
|
+
# '=' counts, so `A= # note` is empty while `A=#x` is the literal '#x'.
|
|
47
|
+
return re.split(r"\s+#", raw, maxsplit=1)[0].strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _closing_quote(raw: str, quote: str) -> int:
|
|
51
|
+
i = 1
|
|
52
|
+
while i < len(raw):
|
|
53
|
+
if raw[i] == "\\" and quote == '"':
|
|
54
|
+
i += 2
|
|
55
|
+
continue
|
|
56
|
+
if raw[i] == quote:
|
|
57
|
+
return i
|
|
58
|
+
i += 1
|
|
59
|
+
raise ValueError(f"missing closing {quote}")
|
tidyenv/_env.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
from tidyenv._dotenv import parse_dotenv
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
U = TypeVar("U")
|
|
17
|
+
|
|
18
|
+
# Env has methods named str/int/list/...; these aliases keep the builtins reachable.
|
|
19
|
+
_str = str
|
|
20
|
+
_int = int
|
|
21
|
+
_float = float
|
|
22
|
+
_bool = bool
|
|
23
|
+
_list = list
|
|
24
|
+
|
|
25
|
+
_MISSING: Any = object()
|
|
26
|
+
_TRUE = {"1", "true", "yes", "y", "on"}
|
|
27
|
+
_FALSE = {"0", "false", "no", "n", "off"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Problem:
|
|
32
|
+
"""One thing that is wrong with the environment."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
message: str
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
return f"{self.name}: {self.message}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EnvError(Exception):
|
|
42
|
+
"""Raised when one or more environment variables are missing or invalid."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, problems: Sequence[Problem]) -> None:
|
|
45
|
+
self.problems = list(problems)
|
|
46
|
+
lines = "\n".join(f" - {p}" for p in self.problems)
|
|
47
|
+
noun = "problem" if len(self.problems) == 1 else "problems"
|
|
48
|
+
super().__init__(f"{len(self.problems)} environment {noun}:\n{lines}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Env:
|
|
52
|
+
"""Reads typed values from the environment.
|
|
53
|
+
|
|
54
|
+
Each reader raises :class:`EnvError` right away, unless it runs inside
|
|
55
|
+
:meth:`collect`, which gathers every problem and raises once at the end.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, environ: Mapping[_str, _str] | None = None, prefix: _str = "") -> None:
|
|
59
|
+
self._environ = environ
|
|
60
|
+
self.prefix = prefix
|
|
61
|
+
self._dotenv: dict[_str, _str] = {}
|
|
62
|
+
# Per thread / async task, so a collect() in one never swallows another's errors.
|
|
63
|
+
self._pending: ContextVar[_list[Problem] | None] = ContextVar(
|
|
64
|
+
"tidyenv_pending", default=None
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def environ(self) -> Mapping[_str, _str]:
|
|
69
|
+
return os.environ if self._environ is None else self._environ
|
|
70
|
+
|
|
71
|
+
def read_dotenv(
|
|
72
|
+
self, path: _str | os.PathLike[_str] = ".env", *, required: _bool = False
|
|
73
|
+
) -> Env:
|
|
74
|
+
"""Use values from a ``.env`` file for variables the environment doesn't set.
|
|
75
|
+
|
|
76
|
+
Real environment variables always win. A missing file is skipped unless
|
|
77
|
+
``required=True``. When several files are read, later ones override
|
|
78
|
+
earlier ones. Nothing is written to ``os.environ``.
|
|
79
|
+
Returns ``self``, so ``env = Env().read_dotenv()`` works.
|
|
80
|
+
"""
|
|
81
|
+
file = Path(path)
|
|
82
|
+
if not file.exists():
|
|
83
|
+
if required:
|
|
84
|
+
raise EnvError([Problem(_str(file), "file not found")])
|
|
85
|
+
return self
|
|
86
|
+
if not file.is_file():
|
|
87
|
+
raise EnvError([Problem(_str(file), "not a file")])
|
|
88
|
+
try:
|
|
89
|
+
# utf-8-sig drops the BOM that some Windows editors add.
|
|
90
|
+
text = file.read_text(encoding="utf-8-sig")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
raise EnvError([Problem(_str(file), "not valid UTF-8")]) from None
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise EnvError([Problem(_str(file), exc.strerror or _str(exc))]) from None
|
|
95
|
+
try:
|
|
96
|
+
self._dotenv.update(parse_dotenv(text))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise EnvError([Problem(_str(file), _str(exc))]) from None
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def collect(self) -> Iterator[Env]:
|
|
103
|
+
"""Gather problems from every read inside the block, then raise them together.
|
|
104
|
+
|
|
105
|
+
Failed reads return ``None`` inside the block, so don't use those
|
|
106
|
+
values until the block has exited cleanly.
|
|
107
|
+
"""
|
|
108
|
+
token = self._pending.set([])
|
|
109
|
+
try:
|
|
110
|
+
yield self
|
|
111
|
+
problems = self._pending.get()
|
|
112
|
+
finally:
|
|
113
|
+
self._pending.reset(token)
|
|
114
|
+
if problems:
|
|
115
|
+
raise EnvError(problems)
|
|
116
|
+
|
|
117
|
+
# -- readers ---------------------------------------------------------
|
|
118
|
+
# Each reader returns its type, or the type of `default` when one is given.
|
|
119
|
+
|
|
120
|
+
@overload
|
|
121
|
+
def str(self, name: _str, *, secret: _bool = ...) -> _str: ...
|
|
122
|
+
@overload
|
|
123
|
+
def str(self, name: _str, default: T, *, secret: _bool = ...) -> _str | T: ...
|
|
124
|
+
def str(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
|
|
125
|
+
"""Read a string. Values are stripped; an empty value counts as missing."""
|
|
126
|
+
return self._read(name, default, lambda raw: raw, secret=secret)
|
|
127
|
+
|
|
128
|
+
@overload
|
|
129
|
+
def int(self, name: _str, *, secret: _bool = ...) -> _int: ...
|
|
130
|
+
@overload
|
|
131
|
+
def int(self, name: _str, default: T, *, secret: _bool = ...) -> _int | T: ...
|
|
132
|
+
def int(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
|
|
133
|
+
"""Read an integer, e.g. ``8000`` or ``1_000``."""
|
|
134
|
+
return self._read(name, default, _parse_int, secret=secret)
|
|
135
|
+
|
|
136
|
+
@overload
|
|
137
|
+
def float(self, name: _str, *, secret: _bool = ...) -> _float: ...
|
|
138
|
+
@overload
|
|
139
|
+
def float(self, name: _str, default: T, *, secret: _bool = ...) -> _float | T: ...
|
|
140
|
+
def float(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
|
|
141
|
+
"""Read a number, e.g. ``0.25``."""
|
|
142
|
+
return self._read(name, default, _parse_float, secret=secret)
|
|
143
|
+
|
|
144
|
+
@overload
|
|
145
|
+
def bool(self, name: _str) -> _bool: ...
|
|
146
|
+
@overload
|
|
147
|
+
def bool(self, name: _str, default: T) -> _bool | T: ...
|
|
148
|
+
def bool(self, name: _str, default: Any = _MISSING) -> Any:
|
|
149
|
+
"""Read a boolean: 1/true/yes/y/on or 0/false/no/n/off, in any case."""
|
|
150
|
+
return self._read(name, default, _parse_bool)
|
|
151
|
+
|
|
152
|
+
@overload
|
|
153
|
+
def list(self, name: _str, *, sep: _str = ..., secret: _bool = ...) -> _list[_str]: ...
|
|
154
|
+
@overload
|
|
155
|
+
def list(
|
|
156
|
+
self, name: _str, default: T, *, sep: _str = ..., secret: _bool = ...
|
|
157
|
+
) -> _list[_str] | T: ...
|
|
158
|
+
@overload
|
|
159
|
+
def list(
|
|
160
|
+
self, name: _str, *, sep: _str = ..., of: Callable[[_str], U], secret: _bool = ...
|
|
161
|
+
) -> _list[U]: ...
|
|
162
|
+
@overload
|
|
163
|
+
def list(
|
|
164
|
+
self,
|
|
165
|
+
name: _str,
|
|
166
|
+
default: T,
|
|
167
|
+
*,
|
|
168
|
+
sep: _str = ...,
|
|
169
|
+
of: Callable[[_str], U],
|
|
170
|
+
secret: _bool = ...,
|
|
171
|
+
) -> _list[U] | T: ...
|
|
172
|
+
def list(
|
|
173
|
+
self,
|
|
174
|
+
name: _str,
|
|
175
|
+
default: Any = _MISSING,
|
|
176
|
+
*,
|
|
177
|
+
sep: _str = ",",
|
|
178
|
+
of: Callable[[_str], Any] = _str,
|
|
179
|
+
secret: _bool = False,
|
|
180
|
+
) -> Any:
|
|
181
|
+
"""Read a separated list, e.g. ``a, b, c``. Items are stripped; blanks dropped.
|
|
182
|
+
|
|
183
|
+
``of`` converts each item, e.g. ``of=int``. ``int``, ``float`` and ``bool``
|
|
184
|
+
use the same rules as the matching readers, so ``of=bool`` understands ``no``.
|
|
185
|
+
"""
|
|
186
|
+
known = of in _ITEM_PARSERS
|
|
187
|
+
convert = _ITEM_PARSERS.get(of, of)
|
|
188
|
+
|
|
189
|
+
def parse(raw: _str) -> _list[Any]:
|
|
190
|
+
items = [item.strip() for item in raw.split(sep)]
|
|
191
|
+
values = []
|
|
192
|
+
for number, item in enumerate((item for item in items if item), start=1):
|
|
193
|
+
try:
|
|
194
|
+
values.append(convert(item))
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
label = f"item {number}" if secret else f"item {number} ({item!r})"
|
|
197
|
+
raise ValueError(f"{label}: {_item_error(exc, known, secret)}") from None
|
|
198
|
+
return values
|
|
199
|
+
|
|
200
|
+
return self._read(name, default, parse, secret=secret)
|
|
201
|
+
|
|
202
|
+
@overload
|
|
203
|
+
def choice(self, name: _str, choices: Sequence[_str]) -> _str: ...
|
|
204
|
+
@overload
|
|
205
|
+
def choice(self, name: _str, choices: Sequence[_str], default: T) -> _str | T: ...
|
|
206
|
+
def choice(self, name: _str, choices: Sequence[_str], default: Any = _MISSING) -> Any:
|
|
207
|
+
"""Read a string that must be one of ``choices``."""
|
|
208
|
+
|
|
209
|
+
def parse(raw: _str) -> _str:
|
|
210
|
+
if raw not in choices:
|
|
211
|
+
raise ValueError(f"expected one of {', '.join(choices)}")
|
|
212
|
+
return raw
|
|
213
|
+
|
|
214
|
+
return self._read(name, default, parse)
|
|
215
|
+
|
|
216
|
+
@overload
|
|
217
|
+
def path(self, name: _str, *, must_exist: _bool = ...) -> Path: ...
|
|
218
|
+
@overload
|
|
219
|
+
def path(self, name: _str, default: T, *, must_exist: _bool = ...) -> Path | T: ...
|
|
220
|
+
def path(self, name: _str, default: Any = _MISSING, *, must_exist: _bool = False) -> Any:
|
|
221
|
+
"""Read a filesystem path, with ``~`` expanded."""
|
|
222
|
+
|
|
223
|
+
def parse(raw: _str) -> Path:
|
|
224
|
+
p = Path(raw).expanduser()
|
|
225
|
+
if must_exist and not p.exists():
|
|
226
|
+
raise ValueError("path does not exist")
|
|
227
|
+
return p
|
|
228
|
+
|
|
229
|
+
return self._read(name, default, parse)
|
|
230
|
+
|
|
231
|
+
def json(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
|
|
232
|
+
"""Read a JSON value, e.g. ``{"a": 1}``."""
|
|
233
|
+
return self._read(name, default, _parse_json, secret=secret)
|
|
234
|
+
|
|
235
|
+
# -- internals -------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _read(
|
|
238
|
+
self,
|
|
239
|
+
name: _str,
|
|
240
|
+
default: Any,
|
|
241
|
+
parse: Callable[[_str], Any],
|
|
242
|
+
*,
|
|
243
|
+
secret: _bool = False,
|
|
244
|
+
) -> Any:
|
|
245
|
+
key = self.prefix + name
|
|
246
|
+
raw = self.environ.get(key)
|
|
247
|
+
if raw is None:
|
|
248
|
+
raw = self._dotenv.get(key)
|
|
249
|
+
if raw is None or raw.strip() == "":
|
|
250
|
+
if default is not _MISSING:
|
|
251
|
+
return default
|
|
252
|
+
return self._fail(key, "is not set")
|
|
253
|
+
try:
|
|
254
|
+
return parse(raw.strip())
|
|
255
|
+
except (ValueError, TypeError) as exc:
|
|
256
|
+
shown = "***" if secret else repr(raw)
|
|
257
|
+
return self._fail(key, f"{exc} (got {shown})")
|
|
258
|
+
|
|
259
|
+
def _fail(self, key: _str, message: _str) -> None:
|
|
260
|
+
problem = Problem(key, message)
|
|
261
|
+
pending = self._pending.get()
|
|
262
|
+
if pending is None:
|
|
263
|
+
raise EnvError([problem])
|
|
264
|
+
pending.append(problem)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_int(raw: str) -> int:
|
|
269
|
+
try:
|
|
270
|
+
return int(raw) # int() already accepts 1_000 but rejects 1__0 and _1
|
|
271
|
+
except ValueError:
|
|
272
|
+
raise ValueError("expected an integer") from None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_float(raw: str) -> float:
|
|
276
|
+
try:
|
|
277
|
+
value = float(raw)
|
|
278
|
+
except ValueError:
|
|
279
|
+
raise ValueError("expected a number") from None
|
|
280
|
+
if not math.isfinite(value):
|
|
281
|
+
raise ValueError("expected a finite number")
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_bool(raw: str) -> bool:
|
|
286
|
+
value = raw.lower()
|
|
287
|
+
if value in _TRUE:
|
|
288
|
+
return True
|
|
289
|
+
if value in _FALSE:
|
|
290
|
+
return False
|
|
291
|
+
raise ValueError("expected true/false, yes/no, on/off or 1/0")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_json(raw: str) -> Any:
|
|
295
|
+
try:
|
|
296
|
+
return _json.loads(raw)
|
|
297
|
+
except _json.JSONDecodeError as exc:
|
|
298
|
+
raise ValueError(f"invalid JSON: {exc.msg}") from None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _parse_str(raw: str) -> str:
|
|
302
|
+
return raw
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Converters for env.list(of=...) that get the readers' rules and friendly messages.
|
|
306
|
+
_ITEM_PARSERS: dict[Callable[[str], Any], Callable[[str], Any]] = {
|
|
307
|
+
str: _parse_str,
|
|
308
|
+
int: _parse_int,
|
|
309
|
+
float: _parse_float,
|
|
310
|
+
bool: _parse_bool,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _item_error(exc: Exception, known: bool, secret: bool) -> str:
|
|
315
|
+
"""Describe why ``of`` rejected a list item without leaking a secret value."""
|
|
316
|
+
if known:
|
|
317
|
+
return str(exc) # our own messages never contain the value
|
|
318
|
+
if secret:
|
|
319
|
+
return "invalid value"
|
|
320
|
+
if isinstance(exc, (ValueError, TypeError)):
|
|
321
|
+
return str(exc)
|
|
322
|
+
return f"{type(exc).__name__}: {exc}"
|
tidyenv/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tidyenv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed environment variables with friendly errors and built-in .env support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/LenaBarretta/tidyenv
|
|
6
|
+
Project-URL: Issues, https://github.com/LenaBarretta/tidyenv/issues
|
|
7
|
+
Author-email: Elena Raikova <lenabarretta@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: 12-factor,config,env,environment,settings
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
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: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://raw.githubusercontent.com/LenaBarretta/tidyenv/main/docs/logo.png" alt="tidyenv logo" width="160">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
# tidyenv
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/tidyenv/)
|
|
33
|
+
[](https://pypi.org/project/tidyenv/)
|
|
34
|
+
[](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**Typed environment variables with friendly errors.** Built-in `.env` support. Zero dependencies.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from tidyenv import env
|
|
41
|
+
|
|
42
|
+
PORT = env.int("PORT", default=8000)
|
|
43
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
44
|
+
HOSTS = env.list("ALLOWED_HOSTS", default=["localhost"])
|
|
45
|
+
DATABASE_URL = env.str("DATABASE_URL")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each line converts the type, applies the default, and if something is wrong, says exactly what:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
tidyenv.EnvError: 1 environment problem:
|
|
52
|
+
- PORT: expected an integer (got 'eighty')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why
|
|
56
|
+
|
|
57
|
+
| | Plain `os.environ` | tidyenv |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| Number with a default | `int(os.environ.get("PORT", "8000"))` | `env.int("PORT", default=8000)` |
|
|
60
|
+
| Bad number | `ValueError: invalid literal for int() with base 10: 'eighty'` (which variable?) | `PORT: expected an integer (got 'eighty')` |
|
|
61
|
+
| Missing variable | `KeyError: 'DB_URL'` | `DB_URL: is not set` |
|
|
62
|
+
| Empty value `DB_URL=` | silently `""` | treated as not set |
|
|
63
|
+
| Boolean | `os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")`, and a typo like `ture` silently means `False` | `env.bool("DEBUG", default=False)`, and `ture` is an error |
|
|
64
|
+
| List | `[h.strip() for h in os.environ.get("HOSTS", "").split(",") if h.strip()]` | `env.list("HOSTS")` |
|
|
65
|
+
| List of numbers | the same, plus `int()` on every item | `env.list("PORTS", of=int)` |
|
|
66
|
+
| One of several values | `if mode not in ("dev", "prod"): raise ...` | `env.choice("MODE", ["dev", "prod"])` |
|
|
67
|
+
| Secrets in errors | the value ends up in your logs | `secret=True` shows `***` |
|
|
68
|
+
| `.env` file | `pip install python-dotenv` + `load_dotenv()` | `env.read_dotenv()`, nothing to install |
|
|
69
|
+
| Types for mypy / IDE | `str \| None`, cast it yourself | `env.int` returns `int` |
|
|
70
|
+
| Several broken variables | crash, fix, redeploy, crash on the next one | `env.collect()` lists them all at once |
|
|
71
|
+
|
|
72
|
+
One line per variable, and every error names the variable and says what is wrong with it.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install tidyenv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Python 3.9+.
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
| Reader | Example value | Returns |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `env.str(name)` | `hello` | `str` (stripped) |
|
|
87
|
+
| `env.int(name)` | `8000`, `1_000` | `int` |
|
|
88
|
+
| `env.float(name)` | `0.25` | `float` (`nan` and `inf` are rejected) |
|
|
89
|
+
| `env.bool(name)` | `true/false`, `yes/no`, `on/off`, `1/0`, any case | `bool` |
|
|
90
|
+
| `env.list(name, sep=",", of=str)` | `a, b, c` | `list` (use `of=int` to convert items; `int`, `float` and `bool` follow the rules above) |
|
|
91
|
+
| `env.choice(name, choices)` | `prod` | `str` that must be in `choices` |
|
|
92
|
+
| `env.path(name, must_exist=False)` | `~/data` | `pathlib.Path` with `~` expanded |
|
|
93
|
+
| `env.json(name)` | `{"a": 1}` | parsed JSON |
|
|
94
|
+
|
|
95
|
+
Every reader takes an optional `default`. Without one, a missing or empty variable is
|
|
96
|
+
an error. With one, the default is returned instead, and type checkers know the result
|
|
97
|
+
is `int | <type of default>`.
|
|
98
|
+
|
|
99
|
+
### `.env` files
|
|
100
|
+
|
|
101
|
+
No need for `python-dotenv`:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
env.read_dotenv() # reads ./.env if it exists
|
|
105
|
+
env.read_dotenv("config/dev.env", required=True)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Real environment variables always win over the file, and nothing is written to
|
|
109
|
+
`os.environ`. When you read several files, later ones override earlier ones.
|
|
110
|
+
Supported syntax: `KEY=value`, `export KEY=value`, `# comments`,
|
|
111
|
+
`'single quotes'` (literal) and `"double quotes"` (with `\n`, `\t`, `\"` escapes).
|
|
112
|
+
Each value must fit on one line; for a multi-line value such as a PEM key, write
|
|
113
|
+
`\n` inside double quotes.
|
|
114
|
+
Need just the parser? `tidyenv.parse_dotenv(text)` returns a `dict`.
|
|
115
|
+
|
|
116
|
+
### All errors at once (optional)
|
|
117
|
+
|
|
118
|
+
By default the first bad variable raises `EnvError`. If you'd rather see every
|
|
119
|
+
problem in one go, wrap your reads in `env.collect()`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
with env.collect():
|
|
123
|
+
PORT = env.int("PORT", default=8000)
|
|
124
|
+
DATABASE_URL = env.str("DATABASE_URL")
|
|
125
|
+
API_KEY = env.str("API_KEY", secret=True)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
tidyenv.EnvError: 3 environment problems:
|
|
130
|
+
- PORT: expected an integer (got 'eighty')
|
|
131
|
+
- DATABASE_URL: is not set
|
|
132
|
+
- API_KEY: is not set
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Failed reads return `None` inside the block, so only use the values after it exits.
|
|
136
|
+
|
|
137
|
+
### More
|
|
138
|
+
|
|
139
|
+
**Secrets.** Pass `secret=True` and the raw value is shown as `***` in error messages,
|
|
140
|
+
including errors about single `env.list` items.
|
|
141
|
+
|
|
142
|
+
**Prefixes and custom sources.** Build your own reader:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from tidyenv import Env
|
|
146
|
+
|
|
147
|
+
env = Env(prefix="MYAPP_") # reads MYAPP_PORT for env.int("PORT")
|
|
148
|
+
test_env = Env(environ={"PORT": "1"}) # any mapping, handy in tests
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Handling errors.** `EnvError.problems` is a list of `Problem(name, message)`, so you
|
|
152
|
+
can print them your own way.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
tidyenv/__init__.py,sha256=1EGuoTp0hMJdIZv9bllA7jzphFl0JiU9-kq_sfRn7Mc,404
|
|
2
|
+
tidyenv/_dotenv.py,sha256=qqOUfLt4BX8OQ5HMi3UiiioSBKaSkl-usmnMfCCZd9c,2158
|
|
3
|
+
tidyenv/_env.py,sha256=oeGQKjrADXefMhuxQTt3RgO4jXFoFwCdcOLTOoDxBUI,11256
|
|
4
|
+
tidyenv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
tidyenv-0.1.0.dist-info/METADATA,sha256=G-fp19rTU9Ah-84idpOV-e3Uk7wIKwzi_n-J_3HkrLs,6192
|
|
6
|
+
tidyenv-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
tidyenv-0.1.0.dist-info/licenses/LICENSE,sha256=KHfqCKAIyFnUFdMWTerpafhQcS240iaDuPMq3w_J67M,1070
|
|
8
|
+
tidyenv-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elena Raikova
|
|
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.
|