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/_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})