envread 0.2.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.
- envread/__init__.py +32 -0
- envread/__main__.py +7 -0
- envread/_check.py +68 -0
- envread/_cli.py +152 -0
- envread/_dotenv.py +105 -0
- envread/_dotenv_value.py +73 -0
- envread/_duration.py +57 -0
- envread/_env.py +136 -0
- envread/_errors.py +98 -0
- envread/_parsers.py +114 -0
- envread/_reader.py +114 -0
- envread/_report.py +88 -0
- envread/_rich.py +119 -0
- envread/_secret.py +64 -0
- envread/_settings.py +95 -0
- envread/_specfile.py +78 -0
- envread/_types.py +110 -0
- envread/_version.py +3 -0
- envread/py.typed +0 -0
- envread-0.2.0.dist-info/METADATA +269 -0
- envread-0.2.0.dist-info/RECORD +24 -0
- envread-0.2.0.dist-info/WHEEL +4 -0
- envread-0.2.0.dist-info/entry_points.txt +2 -0
- envread-0.2.0.dist-info/licenses/LICENSE +21 -0
envread/_errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Exceptions raised when an environment variable is missing or malformed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
MASK = "***"
|
|
8
|
+
"""What a secret value is replaced with in every message and attribute."""
|
|
9
|
+
|
|
10
|
+
_SHOWN = 80
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _shown(value: str) -> str:
|
|
14
|
+
"""Quote a value for a message, trimming very long ones to stay readable."""
|
|
15
|
+
if len(value) <= _SHOWN:
|
|
16
|
+
return repr(value)
|
|
17
|
+
return f"{value[:_SHOWN]!r}... ({len(value)} characters)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvError(Exception):
|
|
21
|
+
"""Base class for every error raised by this package."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MissingEnvError(EnvError, KeyError):
|
|
25
|
+
"""A required variable is not set and no default was given.
|
|
26
|
+
|
|
27
|
+
Subclasses KeyError so existing ``except KeyError`` handlers keep working.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, name: str) -> None:
|
|
31
|
+
self.name = name
|
|
32
|
+
# KeyError.__str__ quotes its argument, so build the message on Exception.
|
|
33
|
+
Exception.__init__(self, f"required environment variable {name!r} is not set")
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return str(self.args[0])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InvalidEnvError(EnvError, ValueError):
|
|
40
|
+
"""A variable is set but its value cannot be parsed as the requested type.
|
|
41
|
+
|
|
42
|
+
With ``secret=True`` the value never reaches the message or the ``value``
|
|
43
|
+
attribute: both carry ``"***"`` instead, and ``reason`` must not quote it either.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
name: str,
|
|
49
|
+
value: str,
|
|
50
|
+
type_name: str,
|
|
51
|
+
reason: str = "",
|
|
52
|
+
*,
|
|
53
|
+
secret: bool = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.name = name
|
|
56
|
+
self.value = MASK if secret else value
|
|
57
|
+
self.type_name = type_name
|
|
58
|
+
self.reason = reason
|
|
59
|
+
self.secret = secret
|
|
60
|
+
shown = MASK if secret else _shown(value)
|
|
61
|
+
suffix = f": {reason}" if reason else ""
|
|
62
|
+
# The message without the variable name, for reports that already show it.
|
|
63
|
+
self.detail = f"not a valid {type_name}: {shown}{suffix}"
|
|
64
|
+
super().__init__(f"environment variable {name!r} is {self.detail}")
|
|
65
|
+
|
|
66
|
+
def masked(self) -> InvalidEnvError:
|
|
67
|
+
"""Return a copy of this error that hides the value."""
|
|
68
|
+
return InvalidEnvError(self.name, MASK, self.type_name, self.reason, secret=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EnvValidationError(EnvError, ValueError):
|
|
72
|
+
"""Several variables failed at once; ``errors`` holds every one of them.
|
|
73
|
+
|
|
74
|
+
Raised by :func:`envread.load` after it has read every field, so a single run
|
|
75
|
+
reports all missing and invalid variables instead of stopping at the first.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, errors: Iterable[MissingEnvError | InvalidEnvError]) -> None:
|
|
79
|
+
self.errors: tuple[MissingEnvError | InvalidEnvError, ...] = tuple(errors)
|
|
80
|
+
count = len(self.errors)
|
|
81
|
+
noun = "variable" if count == 1 else "variables"
|
|
82
|
+
lines = [f"{count} environment {noun} failed validation:"]
|
|
83
|
+
lines.extend(f" - {error}" for error in self.errors)
|
|
84
|
+
super().__init__("\n".join(lines))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DotenvError(EnvError, ValueError):
|
|
88
|
+
"""A ``.env`` file uses syntax outside the supported subset.
|
|
89
|
+
|
|
90
|
+
The message names the file and line but never quotes the offending value.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, reason: str, line: int, path: str | None = None) -> None:
|
|
94
|
+
self.reason = reason
|
|
95
|
+
self.line = line
|
|
96
|
+
self.path = path
|
|
97
|
+
where = f"{path}:{line}" if path else f"line {line}"
|
|
98
|
+
super().__init__(f"{where}: {reason}")
|
envread/_parsers.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Pure string-to-value parsers, each raising InvalidEnvError on bad input.
|
|
2
|
+
|
|
3
|
+
Every parser takes ``(name, raw)`` and keeps its error ``reason`` free of the raw
|
|
4
|
+
value, so the caller can mask a secret without the value leaking through the reason.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as _json
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
from ._errors import InvalidEnvError
|
|
16
|
+
|
|
17
|
+
# Same words as the DEFAULT vocabulary of the sibling library boolish, matched after
|
|
18
|
+
# trimming and lower-casing.
|
|
19
|
+
TRUE_WORDS = frozenset({"1", "true", "yes", "on", "y", "t", "enable", "enabled"})
|
|
20
|
+
FALSE_WORDS = frozenset({"0", "false", "no", "off", "n", "f", "disable", "disabled"})
|
|
21
|
+
|
|
22
|
+
E = TypeVar("E", bound=Enum)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_str(name: str, raw: str) -> str:
|
|
26
|
+
return raw
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_int(name: str, raw: str) -> int:
|
|
30
|
+
try:
|
|
31
|
+
return int(raw.strip())
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise InvalidEnvError(name, raw, "integer") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_float(name: str, raw: str) -> float:
|
|
37
|
+
try:
|
|
38
|
+
return float(raw.strip())
|
|
39
|
+
except ValueError:
|
|
40
|
+
raise InvalidEnvError(name, raw, "float") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_bool(name: str, raw: str) -> bool:
|
|
44
|
+
value = raw.strip().lower()
|
|
45
|
+
if value in TRUE_WORDS:
|
|
46
|
+
return True
|
|
47
|
+
if value in FALSE_WORDS:
|
|
48
|
+
return False
|
|
49
|
+
raise InvalidEnvError(
|
|
50
|
+
name,
|
|
51
|
+
raw,
|
|
52
|
+
"boolean",
|
|
53
|
+
"expected one of true/false, yes/no, on/off, 1/0, enabled/disabled",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_decimal(name: str, raw: str) -> Decimal:
|
|
58
|
+
try:
|
|
59
|
+
value = Decimal(raw.strip())
|
|
60
|
+
except InvalidOperation:
|
|
61
|
+
raise InvalidEnvError(name, raw, "decimal") from None
|
|
62
|
+
if not value.is_finite():
|
|
63
|
+
raise InvalidEnvError(name, raw, "decimal", "must be a finite number")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_path(name: str, raw: str) -> Path:
|
|
68
|
+
if not raw.strip():
|
|
69
|
+
raise InvalidEnvError(name, raw, "path", "must not be empty")
|
|
70
|
+
return Path(raw)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_list(raw: str, separator: str) -> list[str]:
|
|
74
|
+
stripped = raw.strip()
|
|
75
|
+
if not stripped:
|
|
76
|
+
return []
|
|
77
|
+
return [item.strip() for item in stripped.split(separator)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_list(name: str, raw: str, separator: str) -> list[str]:
|
|
81
|
+
return split_list(raw, separator)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_json(name: str, raw: str) -> Any:
|
|
85
|
+
try:
|
|
86
|
+
return _json.loads(raw)
|
|
87
|
+
except _json.JSONDecodeError as exc:
|
|
88
|
+
reason = f"{exc.msg} at line {exc.lineno} column {exc.colno}"
|
|
89
|
+
raise InvalidEnvError(name, raw, "JSON", reason) from None
|
|
90
|
+
except (ValueError, RecursionError):
|
|
91
|
+
# Integers past the digit limit and pathologically deep nesting land here.
|
|
92
|
+
raise InvalidEnvError(name, raw, "JSON", "too large or too deeply nested") from None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_enum(name: str, raw: str, enum_type: type[E]) -> E:
|
|
96
|
+
"""Match a member by name (case-insensitive) first, then by its value as text."""
|
|
97
|
+
value = raw.strip()
|
|
98
|
+
folded = value.casefold()
|
|
99
|
+
for member in enum_type:
|
|
100
|
+
if member.name.casefold() == folded:
|
|
101
|
+
return member
|
|
102
|
+
for member in enum_type:
|
|
103
|
+
if str(member.value) == value:
|
|
104
|
+
return member
|
|
105
|
+
choices = ", ".join(member.name for member in enum_type)
|
|
106
|
+
raise InvalidEnvError(name, raw, enum_type.__name__, f"expected one of {choices}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_choice(name: str, raw: str, choices: tuple[str, ...]) -> str:
|
|
110
|
+
"""Accept one of a fixed set of strings, compared exactly after trimming."""
|
|
111
|
+
value = raw.strip()
|
|
112
|
+
if value in choices:
|
|
113
|
+
return value
|
|
114
|
+
raise InvalidEnvError(name, raw, "choice", f"expected one of {', '.join(choices)}")
|
envread/_reader.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The lookup core shared by every getter: prefix, empty handling, secret masking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from typing import Any, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from ._errors import InvalidEnvError, MissingEnvError
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
R = TypeVar("R", bound="Reader")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Missing:
|
|
16
|
+
"""Sentinel telling "no argument given" apart from an explicit None default."""
|
|
17
|
+
|
|
18
|
+
def __repr__(self) -> str:
|
|
19
|
+
return "<missing>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MISSING: Any = _Missing()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Reader:
|
|
26
|
+
"""Look names up in a mapping (``os.environ`` by default) and parse the values.
|
|
27
|
+
|
|
28
|
+
``prefix`` is prepended to every name, so ``Env(prefix="APP_").int("PORT")`` reads
|
|
29
|
+
``APP_PORT`` and errors name ``APP_PORT``. With ``empty_is_unset=True`` a variable
|
|
30
|
+
set to the empty string counts as unset, which matches how many deployment tools
|
|
31
|
+
write "no value" (``KEY=``).
|
|
32
|
+
|
|
33
|
+
Name matching is whatever the mapping does: ``os.environ`` is case-insensitive on
|
|
34
|
+
Windows and case-sensitive on POSIX, while a plain ``dict`` is always exact.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
source: Mapping[str, str] | None = None,
|
|
40
|
+
*,
|
|
41
|
+
prefix: str = "",
|
|
42
|
+
empty_is_unset: bool = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._source: Mapping[str, str] = source if source is not None else os.environ
|
|
45
|
+
self.prefix = prefix
|
|
46
|
+
self.empty_is_unset = empty_is_unset
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def source(self) -> Mapping[str, str]:
|
|
50
|
+
"""The mapping values are read from."""
|
|
51
|
+
return self._source
|
|
52
|
+
|
|
53
|
+
def prefixed(self: R, prefix: str) -> R:
|
|
54
|
+
"""Return a reader over the same source with ``prefix`` appended to the prefix."""
|
|
55
|
+
return type(self)(
|
|
56
|
+
self._source, prefix=self.prefix + prefix, empty_is_unset=self.empty_is_unset
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def full_name(self, name: str) -> str:
|
|
60
|
+
"""The real variable name read for ``name``, prefix included."""
|
|
61
|
+
return self.prefix + name
|
|
62
|
+
|
|
63
|
+
def raw(self, name: str) -> str | None:
|
|
64
|
+
"""Return the unparsed value of ``name``, or None when it is unset."""
|
|
65
|
+
value = self._source.get(self.full_name(name))
|
|
66
|
+
if value is not None and value == "" and self.empty_is_unset:
|
|
67
|
+
return None
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
def is_set(self, name: str) -> bool:
|
|
71
|
+
"""Whether ``name`` is set (an empty value counts unless ``empty_is_unset``)."""
|
|
72
|
+
return self.raw(name) is not None
|
|
73
|
+
|
|
74
|
+
def __contains__(self, name: object) -> bool:
|
|
75
|
+
return isinstance(name, str) and self.is_set(name)
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
where = "os.environ" if self._source is os.environ else type(self._source).__name__
|
|
79
|
+
return (
|
|
80
|
+
f"{type(self).__name__}(source={where}, prefix={self.prefix!r}, "
|
|
81
|
+
f"empty_is_unset={self.empty_is_unset!r})"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _get(
|
|
85
|
+
self,
|
|
86
|
+
name: str,
|
|
87
|
+
parser: Callable[[str, str], T],
|
|
88
|
+
default: Any,
|
|
89
|
+
required: Any,
|
|
90
|
+
secret: bool = False,
|
|
91
|
+
) -> T | None:
|
|
92
|
+
full = self.full_name(name)
|
|
93
|
+
raw = self.raw(name)
|
|
94
|
+
if raw is not None:
|
|
95
|
+
return parse_value(parser, full, raw, secret)
|
|
96
|
+
if required is True:
|
|
97
|
+
raise MissingEnvError(full)
|
|
98
|
+
if default is not MISSING:
|
|
99
|
+
return cast("T | None", default)
|
|
100
|
+
if required is False:
|
|
101
|
+
return None
|
|
102
|
+
raise MissingEnvError(full)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_value(parser: Callable[[str, str], T], name: str, raw: str, secret: bool) -> T:
|
|
106
|
+
"""Run ``parser``; for a secret, re-raise outside the handler so no context leaks."""
|
|
107
|
+
if not secret:
|
|
108
|
+
return parser(name, raw)
|
|
109
|
+
failure: InvalidEnvError | None = None
|
|
110
|
+
try:
|
|
111
|
+
return parser(name, raw)
|
|
112
|
+
except InvalidEnvError as exc:
|
|
113
|
+
failure = exc.masked()
|
|
114
|
+
raise failure
|
envread/_report.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Render check outcomes as an aligned text table or as JSON, never leaking secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._check import Outcome
|
|
14
|
+
from ._errors import MASK, InvalidEnvError
|
|
15
|
+
from ._secret import Secret
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical(value: Any) -> str:
|
|
19
|
+
"""Format a parsed value the way the command line prints it."""
|
|
20
|
+
if isinstance(value, Secret):
|
|
21
|
+
return MASK
|
|
22
|
+
if isinstance(value, bool):
|
|
23
|
+
return "true" if value else "false"
|
|
24
|
+
if isinstance(value, timedelta):
|
|
25
|
+
seconds = value.total_seconds()
|
|
26
|
+
return str(int(seconds)) if seconds.is_integer() else str(seconds)
|
|
27
|
+
if isinstance(value, Enum):
|
|
28
|
+
return value.name
|
|
29
|
+
if isinstance(value, (Decimal, Path, int, float, str)):
|
|
30
|
+
return str(value)
|
|
31
|
+
if value is None:
|
|
32
|
+
return ""
|
|
33
|
+
return json.dumps(value, default=canonical, ensure_ascii=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _shown(outcome: Outcome) -> str:
|
|
37
|
+
if outcome.spec.secret:
|
|
38
|
+
return MASK
|
|
39
|
+
return canonical(outcome.value) if outcome.status in ("ok", "unset") else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _detail(outcome: Outcome, show_values: bool) -> str:
|
|
43
|
+
kind = outcome.spec.type_name + ("" if outcome.spec.required else ", optional")
|
|
44
|
+
if outcome.spec.secret and outcome.spec.type_name != "secret":
|
|
45
|
+
kind += ", secret"
|
|
46
|
+
if isinstance(outcome.error, InvalidEnvError):
|
|
47
|
+
return f"{kind}: {outcome.error.detail}"
|
|
48
|
+
if outcome.status == "missing":
|
|
49
|
+
return f"{kind}: not set"
|
|
50
|
+
if show_values and (outcome.status == "ok" or outcome.value is not None):
|
|
51
|
+
return f"{kind} = {_shown(outcome)}"
|
|
52
|
+
return kind
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_text(outcomes: Sequence[Outcome], show_values: bool) -> str:
|
|
56
|
+
width = max((len(outcome.full_name) for outcome in outcomes), default=0)
|
|
57
|
+
lines = [
|
|
58
|
+
f"{outcome.status:<8} {outcome.full_name:<{width}} {_detail(outcome, show_values)}"
|
|
59
|
+
for outcome in outcomes
|
|
60
|
+
]
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_json(outcomes: Sequence[Outcome], show_values: bool) -> str:
|
|
65
|
+
rows = []
|
|
66
|
+
for outcome in outcomes:
|
|
67
|
+
row: dict[str, Any] = {
|
|
68
|
+
"name": outcome.full_name,
|
|
69
|
+
"type": outcome.spec.type_name,
|
|
70
|
+
"status": outcome.status,
|
|
71
|
+
"required": outcome.spec.required,
|
|
72
|
+
"secret": outcome.spec.secret,
|
|
73
|
+
"error": None if outcome.error is None else str(outcome.error),
|
|
74
|
+
}
|
|
75
|
+
if show_values and outcome.status in ("ok", "unset"):
|
|
76
|
+
row["value"] = _shown(outcome)
|
|
77
|
+
rows.append(row)
|
|
78
|
+
ok = not any(outcome.failed for outcome in outcomes)
|
|
79
|
+
return json.dumps({"ok": ok, "variables": rows}, indent=2, ensure_ascii=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def summary(outcomes: Sequence[Outcome]) -> str:
|
|
83
|
+
failed = sum(1 for outcome in outcomes if outcome.failed)
|
|
84
|
+
total = len(outcomes)
|
|
85
|
+
noun = "variable" if total == 1 else "variables"
|
|
86
|
+
if failed:
|
|
87
|
+
return f"{failed} of {total} {noun} failed"
|
|
88
|
+
return f"all {total} {noun} ok"
|
envread/_rich.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Getters for richer types: Decimal, Path, timedelta, Enum members and secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, TypeVar, overload
|
|
10
|
+
|
|
11
|
+
from ._duration import parse_duration
|
|
12
|
+
from ._parsers import parse_decimal, parse_enum, parse_path, parse_str
|
|
13
|
+
from ._reader import MISSING, Reader
|
|
14
|
+
from ._secret import Secret
|
|
15
|
+
|
|
16
|
+
E = TypeVar("E", bound=Enum)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RichGetters(Reader):
|
|
20
|
+
"""Overloaded getters whose return type follows the default and ``required=``."""
|
|
21
|
+
|
|
22
|
+
@overload
|
|
23
|
+
def decimal(self, name: str, *, secret: bool = ...) -> Decimal: ...
|
|
24
|
+
@overload
|
|
25
|
+
def decimal(self, name: str, default: Decimal, *, secret: bool = ...) -> Decimal: ...
|
|
26
|
+
@overload
|
|
27
|
+
def decimal(self, name: str, default: None, *, secret: bool = ...) -> Decimal | None: ...
|
|
28
|
+
@overload
|
|
29
|
+
def decimal(self, name: str, *, required: Literal[True], secret: bool = ...) -> Decimal: ...
|
|
30
|
+
@overload
|
|
31
|
+
def decimal(
|
|
32
|
+
self, name: str, *, required: Literal[False], secret: bool = ...
|
|
33
|
+
) -> Decimal | None: ...
|
|
34
|
+
def decimal(
|
|
35
|
+
self, name: str, default: Any = MISSING, *, required: Any = MISSING, secret: bool = False
|
|
36
|
+
) -> Decimal | None:
|
|
37
|
+
"""Parse an exact decimal such as ``19.99``; NaN and infinities are rejected."""
|
|
38
|
+
return self._get(name, parse_decimal, default, required, secret)
|
|
39
|
+
|
|
40
|
+
@overload
|
|
41
|
+
def path(self, name: str, *, secret: bool = ...) -> Path: ...
|
|
42
|
+
@overload
|
|
43
|
+
def path(self, name: str, default: Path, *, secret: bool = ...) -> Path: ...
|
|
44
|
+
@overload
|
|
45
|
+
def path(self, name: str, default: None, *, secret: bool = ...) -> Path | None: ...
|
|
46
|
+
@overload
|
|
47
|
+
def path(self, name: str, *, required: Literal[True], secret: bool = ...) -> Path: ...
|
|
48
|
+
@overload
|
|
49
|
+
def path(self, name: str, *, required: Literal[False], secret: bool = ...) -> Path | None: ...
|
|
50
|
+
def path(
|
|
51
|
+
self, name: str, default: Any = MISSING, *, required: Any = MISSING, secret: bool = False
|
|
52
|
+
) -> Path | None:
|
|
53
|
+
"""Return a ``pathlib.Path``, taken literally (no ``~`` or variable expansion)."""
|
|
54
|
+
return self._get(name, parse_path, default, required, secret)
|
|
55
|
+
|
|
56
|
+
@overload
|
|
57
|
+
def duration(self, name: str, *, secret: bool = ...) -> timedelta: ...
|
|
58
|
+
@overload
|
|
59
|
+
def duration(self, name: str, default: timedelta, *, secret: bool = ...) -> timedelta: ...
|
|
60
|
+
@overload
|
|
61
|
+
def duration(self, name: str, default: None, *, secret: bool = ...) -> timedelta | None: ...
|
|
62
|
+
@overload
|
|
63
|
+
def duration(self, name: str, *, required: Literal[True], secret: bool = ...) -> timedelta: ...
|
|
64
|
+
@overload
|
|
65
|
+
def duration(
|
|
66
|
+
self, name: str, *, required: Literal[False], secret: bool = ...
|
|
67
|
+
) -> timedelta | None: ...
|
|
68
|
+
def duration(
|
|
69
|
+
self, name: str, default: Any = MISSING, *, required: Any = MISSING, secret: bool = False
|
|
70
|
+
) -> timedelta | None:
|
|
71
|
+
"""Parse ``90`` (seconds), ``250ms``, ``1h30m`` or ``1.5h`` into a timedelta."""
|
|
72
|
+
return self._get(name, parse_duration, default, required, secret)
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def enum(self, name: str, enum_type: type[E], *, secret: bool = ...) -> E: ...
|
|
76
|
+
@overload
|
|
77
|
+
def enum(self, name: str, enum_type: type[E], default: E, *, secret: bool = ...) -> E: ...
|
|
78
|
+
@overload
|
|
79
|
+
def enum(
|
|
80
|
+
self, name: str, enum_type: type[E], default: None, *, secret: bool = ...
|
|
81
|
+
) -> E | None: ...
|
|
82
|
+
@overload
|
|
83
|
+
def enum(
|
|
84
|
+
self, name: str, enum_type: type[E], *, required: Literal[True], secret: bool = ...
|
|
85
|
+
) -> E: ...
|
|
86
|
+
@overload
|
|
87
|
+
def enum(
|
|
88
|
+
self, name: str, enum_type: type[E], *, required: Literal[False], secret: bool = ...
|
|
89
|
+
) -> E | None: ...
|
|
90
|
+
def enum(
|
|
91
|
+
self,
|
|
92
|
+
name: str,
|
|
93
|
+
enum_type: type[E],
|
|
94
|
+
default: Any = MISSING,
|
|
95
|
+
*,
|
|
96
|
+
required: Any = MISSING,
|
|
97
|
+
secret: bool = False,
|
|
98
|
+
) -> E | None:
|
|
99
|
+
"""Return the member named by the value (any case), or whose value matches it."""
|
|
100
|
+
return self._get(
|
|
101
|
+
name, lambda n, raw: parse_enum(n, raw, enum_type), default, required, secret
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@overload
|
|
105
|
+
def secret(self, name: str) -> Secret: ...
|
|
106
|
+
@overload
|
|
107
|
+
def secret(self, name: str, default: Secret) -> Secret: ...
|
|
108
|
+
@overload
|
|
109
|
+
def secret(self, name: str, default: None) -> Secret | None: ...
|
|
110
|
+
@overload
|
|
111
|
+
def secret(self, name: str, *, required: Literal[True]) -> Secret: ...
|
|
112
|
+
@overload
|
|
113
|
+
def secret(self, name: str, *, required: Literal[False]) -> Secret | None: ...
|
|
114
|
+
def secret(
|
|
115
|
+
self, name: str, default: Any = MISSING, *, required: Any = MISSING
|
|
116
|
+
) -> Secret | None:
|
|
117
|
+
"""Return the value wrapped in :class:`Secret`, which never shows it in a repr."""
|
|
118
|
+
value = self._get(name, parse_str, default, required, True)
|
|
119
|
+
return Secret(value) if isinstance(value, str) else value
|
envread/_secret.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A string wrapper that keeps a sensitive value out of reprs, logs and tracebacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
from typing import Any, NoReturn
|
|
7
|
+
|
|
8
|
+
from ._errors import MASK
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Secret:
|
|
12
|
+
"""Hold a sensitive string and only hand it out through :meth:`reveal`.
|
|
13
|
+
|
|
14
|
+
``str()``, ``repr()``, ``format()`` and f-strings all show ``***``, so a settings
|
|
15
|
+
object that holds a ``Secret`` can be printed or logged without leaking it.
|
|
16
|
+
Equality compares the hidden values in constant time. Pickling is refused, so the
|
|
17
|
+
value cannot slip into a cache or a queue by accident.
|
|
18
|
+
|
|
19
|
+
>>> token = Secret("hunter2")
|
|
20
|
+
>>> token
|
|
21
|
+
Secret('***')
|
|
22
|
+
>>> token.reveal()
|
|
23
|
+
'hunter2'
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("_value",)
|
|
27
|
+
|
|
28
|
+
def __init__(self, value: str) -> None:
|
|
29
|
+
if not isinstance(value, str):
|
|
30
|
+
raise TypeError(f"Secret holds a str, not {type(value).__name__}")
|
|
31
|
+
self._value = value
|
|
32
|
+
|
|
33
|
+
def reveal(self) -> str:
|
|
34
|
+
"""Return the hidden value. Call it only where the value is really needed."""
|
|
35
|
+
return self._value
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"Secret({MASK!r})"
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return MASK
|
|
42
|
+
|
|
43
|
+
def __format__(self, spec: str) -> str:
|
|
44
|
+
return format(MASK, spec)
|
|
45
|
+
|
|
46
|
+
def __eq__(self, other: object) -> bool:
|
|
47
|
+
if not isinstance(other, Secret):
|
|
48
|
+
return NotImplemented
|
|
49
|
+
return hmac.compare_digest(self._value.encode(), other._value.encode())
|
|
50
|
+
|
|
51
|
+
def __hash__(self) -> int:
|
|
52
|
+
return hash((Secret, self._value))
|
|
53
|
+
|
|
54
|
+
def __bool__(self) -> bool:
|
|
55
|
+
return bool(self._value)
|
|
56
|
+
|
|
57
|
+
def __reduce__(self) -> NoReturn:
|
|
58
|
+
raise TypeError("Secret values cannot be pickled")
|
|
59
|
+
|
|
60
|
+
def __copy__(self) -> Secret:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> Secret:
|
|
64
|
+
return self
|
envread/_settings.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Populate a plain dataclass from the environment, reporting every failure at once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, TypeVar, get_type_hints
|
|
8
|
+
|
|
9
|
+
from ._check import VarSpec, evaluate
|
|
10
|
+
from ._env import Env, env
|
|
11
|
+
from ._errors import EnvValidationError
|
|
12
|
+
from ._reader import MISSING
|
|
13
|
+
from ._secret import Secret
|
|
14
|
+
from ._types import resolve, unwrap_optional
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
_KEY = "envread"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True)
|
|
21
|
+
class _VarOptions:
|
|
22
|
+
name: str | None
|
|
23
|
+
separator: str
|
|
24
|
+
secret: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def var(
|
|
28
|
+
name: str | None = None,
|
|
29
|
+
*,
|
|
30
|
+
default: Any = MISSING,
|
|
31
|
+
default_factory: Callable[[], Any] | None = None,
|
|
32
|
+
separator: str = ",",
|
|
33
|
+
secret: bool = False,
|
|
34
|
+
) -> Any:
|
|
35
|
+
"""Describe how a dataclass field is read; use it where you would use ``field()``.
|
|
36
|
+
|
|
37
|
+
``name`` overrides the variable name (the field name upper-cased by default; any
|
|
38
|
+
prefix still applies). ``default`` / ``default_factory`` make the field optional,
|
|
39
|
+
``separator`` splits ``list[...]`` and ``tuple[..., ...]`` fields, and ``secret``
|
|
40
|
+
keeps the value out of error messages.
|
|
41
|
+
"""
|
|
42
|
+
if default is not MISSING and default_factory is not None:
|
|
43
|
+
raise ValueError("pass default or default_factory, not both")
|
|
44
|
+
metadata = {_KEY: _VarOptions(name, separator, secret)}
|
|
45
|
+
if default_factory is not None:
|
|
46
|
+
return dataclasses.field(default_factory=default_factory, metadata=metadata)
|
|
47
|
+
if default is not MISSING:
|
|
48
|
+
return dataclasses.field(default=default, metadata=metadata)
|
|
49
|
+
return dataclasses.field(metadata=metadata)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def specs_for(cls: type[Any]) -> list[VarSpec]:
|
|
53
|
+
"""Build one spec per init field of the dataclass ``cls``."""
|
|
54
|
+
if not (isinstance(cls, type) and dataclasses.is_dataclass(cls)):
|
|
55
|
+
raise TypeError(f"expected a dataclass type, got {cls!r}")
|
|
56
|
+
try:
|
|
57
|
+
hints = get_type_hints(cls)
|
|
58
|
+
except NameError as exc:
|
|
59
|
+
raise TypeError(f"cannot resolve the annotations of {cls.__qualname__}: {exc}") from None
|
|
60
|
+
return [_spec(field, hints[field.name]) for field in dataclasses.fields(cls) if field.init]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _spec(field: dataclasses.Field[Any], annotation: Any) -> VarSpec:
|
|
64
|
+
options = field.metadata.get(_KEY) or _VarOptions(None, ",", False)
|
|
65
|
+
inner, optional = unwrap_optional(annotation)
|
|
66
|
+
parser, type_name = resolve(inner, options.separator)
|
|
67
|
+
has_default = field.default is not dataclasses.MISSING
|
|
68
|
+
factory = field.default_factory
|
|
69
|
+
has_factory = factory is not dataclasses.MISSING
|
|
70
|
+
return VarSpec(
|
|
71
|
+
name=options.name or field.name.upper(),
|
|
72
|
+
type_name=type_name,
|
|
73
|
+
parser=parser,
|
|
74
|
+
required=not (has_default or has_factory or optional),
|
|
75
|
+
default=field.default if has_default else MISSING,
|
|
76
|
+
default_factory=factory if has_factory else None, # type: ignore[arg-type]
|
|
77
|
+
secret=options.secret or inner is Secret,
|
|
78
|
+
attr=field.name,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load(cls: type[T], source: Env | None = None) -> T:
|
|
83
|
+
"""Read every field of the dataclass ``cls`` from the environment and build it.
|
|
84
|
+
|
|
85
|
+
Each field is read from the upper-cased field name (or ``var(name=...)``), with
|
|
86
|
+
the prefix of ``source`` applied (``Env(prefix="APP_")``). A field with a default,
|
|
87
|
+
a ``default_factory`` or an ``X | None`` annotation is optional; any other field
|
|
88
|
+
is required. Every field is read before anything is raised, so one
|
|
89
|
+
:class:`EnvValidationError` lists all missing and invalid variables together.
|
|
90
|
+
"""
|
|
91
|
+
outcomes = evaluate(specs_for(cls), source if source is not None else env)
|
|
92
|
+
errors = [outcome.error for outcome in outcomes if outcome.error is not None]
|
|
93
|
+
if errors:
|
|
94
|
+
raise EnvValidationError(errors)
|
|
95
|
+
return cls(**{outcome.spec.attr: outcome.value for outcome in outcomes})
|