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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Typed, zero-dependency reader for environment variables with mypy-inferred types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._dotenv import load_dotenv, parse_dotenv, read_dotenv
|
|
6
|
+
from ._env import Env, env
|
|
7
|
+
from ._errors import (
|
|
8
|
+
DotenvError,
|
|
9
|
+
EnvError,
|
|
10
|
+
EnvValidationError,
|
|
11
|
+
InvalidEnvError,
|
|
12
|
+
MissingEnvError,
|
|
13
|
+
)
|
|
14
|
+
from ._secret import Secret
|
|
15
|
+
from ._settings import load, var
|
|
16
|
+
from ._version import __version__ as __version__
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DotenvError",
|
|
20
|
+
"Env",
|
|
21
|
+
"EnvError",
|
|
22
|
+
"EnvValidationError",
|
|
23
|
+
"InvalidEnvError",
|
|
24
|
+
"MissingEnvError",
|
|
25
|
+
"Secret",
|
|
26
|
+
"env",
|
|
27
|
+
"load",
|
|
28
|
+
"load_dotenv",
|
|
29
|
+
"parse_dotenv",
|
|
30
|
+
"read_dotenv",
|
|
31
|
+
"var",
|
|
32
|
+
]
|
envread/__main__.py
ADDED
envread/_check.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Evaluate a list of variable specs against a reader, collecting every outcome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._errors import InvalidEnvError, MissingEnvError
|
|
10
|
+
from ._reader import MISSING, Reader, parse_value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class VarSpec:
|
|
15
|
+
"""One variable to read: its name, parser and what to do when it is unset."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
type_name: str
|
|
19
|
+
parser: Callable[[str, str], Any]
|
|
20
|
+
required: bool = True
|
|
21
|
+
default: Any = MISSING
|
|
22
|
+
default_factory: Callable[[], Any] | None = None
|
|
23
|
+
secret: bool = False
|
|
24
|
+
attr: str = ""
|
|
25
|
+
|
|
26
|
+
def fallback(self) -> Any:
|
|
27
|
+
"""The value used when the variable is unset and not required."""
|
|
28
|
+
if self.default_factory is not None:
|
|
29
|
+
return self.default_factory()
|
|
30
|
+
return None if self.default is MISSING else self.default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Outcome:
|
|
35
|
+
"""The result of reading one spec.
|
|
36
|
+
|
|
37
|
+
``status`` is ``"ok"`` (set and valid), ``"unset"`` (unset but optional, so
|
|
38
|
+
``value`` is the fallback), ``"missing"`` or ``"invalid"`` (``error`` is set).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
spec: VarSpec
|
|
42
|
+
full_name: str
|
|
43
|
+
status: str
|
|
44
|
+
value: Any = None
|
|
45
|
+
error: MissingEnvError | InvalidEnvError | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def failed(self) -> bool:
|
|
49
|
+
return self.error is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def evaluate_one(spec: VarSpec, reader: Reader) -> Outcome:
|
|
53
|
+
full = reader.full_name(spec.name)
|
|
54
|
+
raw = reader.raw(spec.name)
|
|
55
|
+
if raw is None:
|
|
56
|
+
if spec.required:
|
|
57
|
+
return Outcome(spec, full, "missing", error=MissingEnvError(full))
|
|
58
|
+
return Outcome(spec, full, "unset", spec.fallback())
|
|
59
|
+
try:
|
|
60
|
+
value = parse_value(spec.parser, full, raw, spec.secret)
|
|
61
|
+
except InvalidEnvError as exc:
|
|
62
|
+
return Outcome(spec, full, "invalid", error=exc)
|
|
63
|
+
return Outcome(spec, full, "ok", value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def evaluate(specs: Iterable[VarSpec], reader: Reader) -> list[Outcome]:
|
|
67
|
+
"""Read every spec, never stopping at the first failure."""
|
|
68
|
+
return [evaluate_one(spec, reader) for spec in specs]
|
envread/_cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The ``envread`` command: validate a spec against the environment, or read one value."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections import ChainMap
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, NoReturn
|
|
13
|
+
|
|
14
|
+
from ._check import VarSpec, evaluate, evaluate_one
|
|
15
|
+
from ._dotenv import read_layer
|
|
16
|
+
from ._env import Env
|
|
17
|
+
from ._errors import EnvError
|
|
18
|
+
from ._reader import parse_value
|
|
19
|
+
from ._report import canonical, render_json, render_text, summary
|
|
20
|
+
from ._settings import specs_for
|
|
21
|
+
from ._specfile import TYPE_HELP, SpecError, parse_type, read_spec
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
EPILOG = """\
|
|
25
|
+
examples:
|
|
26
|
+
envread check env.spec validate every variable in a spec file
|
|
27
|
+
envread check myapp.settings:Settings validate the fields of a dataclass
|
|
28
|
+
envread check env.spec --env-file .env layer a .env file under the environment
|
|
29
|
+
envread check env.spec --format json machine-readable report
|
|
30
|
+
envread get PORT --type int --default 8080 print one parsed value
|
|
31
|
+
envread get DEBUG --type bool prints true or false
|
|
32
|
+
|
|
33
|
+
spec file format (one variable per line, # starts a comment):
|
|
34
|
+
DATABASE_URL secret
|
|
35
|
+
PORT int optional
|
|
36
|
+
DEBUG bool optional
|
|
37
|
+
LOG_LEVEL choice:debug,info,warning
|
|
38
|
+
HOSTS list:;
|
|
39
|
+
|
|
40
|
+
check exits 0 when every variable is valid and 1 otherwise; values are only printed
|
|
41
|
+
with --show-values, and secrets never are. Errors go to stderr with exit code 1.
|
|
42
|
+
module:Class is imported with the current directory added to the import path.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Parser(argparse.ArgumentParser):
|
|
47
|
+
def error(self, message: str) -> NoReturn:
|
|
48
|
+
self.print_usage(sys.stderr)
|
|
49
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _common(parser: argparse.ArgumentParser) -> None:
|
|
53
|
+
parser.add_argument("--prefix", default="", help="prepend PREFIX to every name")
|
|
54
|
+
parser.add_argument("--env-file", metavar="FILE", help="read FILE under the environment")
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--empty-is-unset", action="store_true", help="treat variables set to '' as unset"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
61
|
+
parser = _Parser(
|
|
62
|
+
prog="envread",
|
|
63
|
+
description="Validate and read typed environment variables.",
|
|
64
|
+
epilog=EPILOG,
|
|
65
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--version", action="version", version=f"envread {__version__}")
|
|
68
|
+
commands = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
|
|
69
|
+
check = commands.add_parser(
|
|
70
|
+
"check",
|
|
71
|
+
help="validate a spec or dataclass",
|
|
72
|
+
epilog=EPILOG,
|
|
73
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
74
|
+
)
|
|
75
|
+
check.add_argument("target", help="a spec file path, or module:Class for a dataclass")
|
|
76
|
+
_common(check)
|
|
77
|
+
check.add_argument("--format", choices=("text", "json"), default="text")
|
|
78
|
+
check.add_argument("--show-values", action="store_true", help="print values (not secrets)")
|
|
79
|
+
check.add_argument("--quiet", "-q", action="store_true", help="print nothing, exit code only")
|
|
80
|
+
get = commands.add_parser(
|
|
81
|
+
"get",
|
|
82
|
+
help="print one parsed value",
|
|
83
|
+
epilog=EPILOG,
|
|
84
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
85
|
+
)
|
|
86
|
+
get.add_argument("name", help="the variable name (without --prefix)")
|
|
87
|
+
get.add_argument("--type", default="str", help=f"one of {TYPE_HELP} (default str)")
|
|
88
|
+
get.add_argument("--default", help="value to use when the variable is unset")
|
|
89
|
+
get.add_argument("--secret", action="store_true", help="never show the value in errors")
|
|
90
|
+
_common(get)
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _reader(args: argparse.Namespace) -> Env:
|
|
95
|
+
source: Mapping[str, str] = os.environ
|
|
96
|
+
if args.env_file:
|
|
97
|
+
source = ChainMap(os.environ, read_layer(args.env_file))
|
|
98
|
+
return Env(source, prefix=args.prefix, empty_is_unset=args.empty_is_unset)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _specs(target: str) -> list[VarSpec]:
|
|
102
|
+
if Path(target).is_file():
|
|
103
|
+
return read_spec(target)
|
|
104
|
+
module_name, colon, attr = target.partition(":")
|
|
105
|
+
if not colon or not module_name or not attr:
|
|
106
|
+
raise SpecError(f"{target!r} is neither a spec file nor module:Class")
|
|
107
|
+
if os.getcwd() not in sys.path:
|
|
108
|
+
sys.path.insert(0, os.getcwd())
|
|
109
|
+
obj: Any = importlib.import_module(module_name)
|
|
110
|
+
for part in attr.split("."):
|
|
111
|
+
obj = getattr(obj, part)
|
|
112
|
+
return specs_for(obj)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check(args: argparse.Namespace) -> int:
|
|
116
|
+
outcomes = evaluate(_specs(args.target), _reader(args))
|
|
117
|
+
failed = any(outcome.failed for outcome in outcomes)
|
|
118
|
+
if not args.quiet:
|
|
119
|
+
render = render_json if args.format == "json" else render_text
|
|
120
|
+
report = render(outcomes, args.show_values)
|
|
121
|
+
if report:
|
|
122
|
+
print(report)
|
|
123
|
+
if args.format == "text":
|
|
124
|
+
print(f"envread: {summary(outcomes)}", file=sys.stderr if failed else sys.stdout)
|
|
125
|
+
return 1 if failed else 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _get(args: argparse.Namespace) -> int:
|
|
129
|
+
parser, type_name = parse_type(args.type)
|
|
130
|
+
spec = VarSpec(args.name, type_name, parser, required=args.default is None, secret=args.secret)
|
|
131
|
+
outcome = evaluate_one(spec, _reader(args))
|
|
132
|
+
if outcome.error is not None:
|
|
133
|
+
raise outcome.error
|
|
134
|
+
value = outcome.value
|
|
135
|
+
if outcome.status == "unset":
|
|
136
|
+
value = parse_value(parser, f"--default for {outcome.full_name}", args.default, args.secret)
|
|
137
|
+
print("\n".join(value) if isinstance(value, list) else canonical(value))
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
142
|
+
"""Run the command line interface and return the process exit code."""
|
|
143
|
+
args = build_parser().parse_args(argv)
|
|
144
|
+
try:
|
|
145
|
+
return _check(args) if args.command == "check" else _get(args)
|
|
146
|
+
except (EnvError, SpecError, TypeError, ImportError, AttributeError, OSError) as exc:
|
|
147
|
+
print(f"envread: {exc}", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__": # pragma: no cover
|
|
152
|
+
sys.exit(main())
|
envread/_dotenv.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
r"""Read ``.env`` files: a documented subset of python-dotenv 1.x syntax.
|
|
2
|
+
|
|
3
|
+
Supported, matching ``dotenv_values`` / ``load_dotenv``: blank lines, ``#`` comment
|
|
4
|
+
lines, an optional ``export`` keyword, ``KEY=value`` with spaces allowed around ``=``;
|
|
5
|
+
unquoted values (trimmed, an inline comment needs whitespace before ``#``); single
|
|
6
|
+
quotes (literal, only ``\'`` and ``\\`` are escapes, and ``${`` is refused because
|
|
7
|
+
the two references disagree on it); double quotes (escapes
|
|
8
|
+
``\\ \' \" \a \b \f \n \r \t \v``); and ``${NAME}`` / ``${NAME:-default}``
|
|
9
|
+
expansion in unquoted and double-quoted values. A bare ``KEY`` with no ``=`` is
|
|
10
|
+
skipped. Anything else, such as multi-line quoted values or other ``${...}`` forms,
|
|
11
|
+
raises :class:`DotenvError` naming the line, instead of being guessed at.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ._dotenv_value import Expand, parse_value
|
|
23
|
+
from ._errors import DotenvError
|
|
24
|
+
|
|
25
|
+
_LINE = re.compile(r"\s*(?:export\s+)?(?P<key>[^=#\s'\"]+)\s*(?:=(?P<rest>.*))?")
|
|
26
|
+
_WINDOWS_MAX = 32767
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _entries(text: str, path: str | None) -> list[tuple[int, str, str, bool]]:
|
|
30
|
+
"""Return ``(line, key, value, expandable)`` for every assignment line."""
|
|
31
|
+
entries = []
|
|
32
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
33
|
+
stripped = line.strip()
|
|
34
|
+
if not stripped or stripped.startswith("#"):
|
|
35
|
+
continue
|
|
36
|
+
match = _LINE.fullmatch(line)
|
|
37
|
+
if match is None:
|
|
38
|
+
raise DotenvError("expected KEY=value", number, path)
|
|
39
|
+
rest = match.group("rest")
|
|
40
|
+
if rest is None:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
value, expandable = parse_value(rest)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise DotenvError(str(exc), number, path) from None
|
|
46
|
+
entries.append((number, match.group("key"), value, expandable))
|
|
47
|
+
return entries
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve(
|
|
51
|
+
text: str, path: str | None, environ: Mapping[str, str], file_first: bool
|
|
52
|
+
) -> dict[str, str]:
|
|
53
|
+
values: dict[str, str] = {}
|
|
54
|
+
for number, key, raw, expandable in _entries(text, path):
|
|
55
|
+
lookup = Expand(values, environ, file_first)
|
|
56
|
+
try:
|
|
57
|
+
values[key] = lookup.expand(raw) if expandable else raw
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise DotenvError(str(exc), number, path) from None
|
|
60
|
+
return values
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_dotenv(text: str, environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
64
|
+
"""Parse ``.env`` text into a dict, like python-dotenv's ``dotenv_values``.
|
|
65
|
+
|
|
66
|
+
``${NAME}`` resolves against earlier keys in the text first, then ``environ``
|
|
67
|
+
(``os.environ`` by default); an unknown name expands to ``""``.
|
|
68
|
+
"""
|
|
69
|
+
return _resolve(text, None, os.environ if environ is None else environ, True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_dotenv(
|
|
73
|
+
path: str | os.PathLike[str], environ: Mapping[str, str] | None = None
|
|
74
|
+
) -> dict[str, str]:
|
|
75
|
+
"""Read and parse a UTF-8 ``.env`` file without touching the environment."""
|
|
76
|
+
text = Path(path).read_text(encoding="utf-8-sig")
|
|
77
|
+
return _resolve(text, os.fspath(path), os.environ if environ is None else environ, True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_dotenv(path: str | os.PathLike[str], *, override: bool = False) -> dict[str, str]:
|
|
81
|
+
"""Copy the variables of a ``.env`` file into ``os.environ``; return what was set.
|
|
82
|
+
|
|
83
|
+
Like python-dotenv, variables that are already set win unless ``override=True``,
|
|
84
|
+
and the same precedence applies to ``${NAME}`` expansion. On Windows an entry
|
|
85
|
+
whose ``NAME=value`` exceeds 32,767 characters cannot be stored in the process
|
|
86
|
+
environment, so it raises :class:`DotenvError` before anything is written.
|
|
87
|
+
"""
|
|
88
|
+
name = os.fspath(path)
|
|
89
|
+
text = Path(path).read_text(encoding="utf-8-sig")
|
|
90
|
+
values = _resolve(text, name, os.environ, override)
|
|
91
|
+
chosen = {k: v for k, v in values.items() if override or k not in os.environ}
|
|
92
|
+
if sys.platform == "win32":
|
|
93
|
+
lines = {key: number for number, key, *_ in _entries(text, name)}
|
|
94
|
+
for key, value in chosen.items():
|
|
95
|
+
if len(key) + 1 + len(value) > _WINDOWS_MAX:
|
|
96
|
+
reason = f"{key} is longer than the 32767 characters Windows allows"
|
|
97
|
+
raise DotenvError(reason, lines[key], name)
|
|
98
|
+
os.environ.update(chosen)
|
|
99
|
+
return chosen
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_layer(path: str | os.PathLike[str]) -> dict[str, str]:
|
|
103
|
+
"""Parse a file the way :func:`load_dotenv` would see it, without writing anything."""
|
|
104
|
+
text = Path(path).read_text(encoding="utf-8-sig")
|
|
105
|
+
return _resolve(text, os.fspath(path), os.environ, False)
|
envread/_dotenv_value.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Decode the value part of one ``.env`` line and expand ``${NAME}`` references."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
_SINGLE = re.compile(r"'((?:\\'|[^'])*)'")
|
|
9
|
+
_DOUBLE = re.compile(r'"((?:\\"|[^"])*)"')
|
|
10
|
+
_TAIL = re.compile(r"\s*(?:#.*)?")
|
|
11
|
+
_SINGLE_ESCAPES = re.compile(r"\\([\\'])")
|
|
12
|
+
_DOUBLE_ESCAPES = re.compile(r"\\([\\'\"abfnrtv])")
|
|
13
|
+
_CONTROL = {"a": "\a", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t", "v": "\v"}
|
|
14
|
+
_REFERENCE = re.compile(r"\$\{([^}]*)\}")
|
|
15
|
+
_PLAIN = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?")
|
|
16
|
+
# python-dotenv 1.x expands ${...} inside single quotes while docker compose keeps it
|
|
17
|
+
# literal, so the subset refuses the construct rather than pick one silently.
|
|
18
|
+
_AMBIGUOUS = "${ inside single quotes is read differently by python-dotenv and docker compose"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_value(rest: str) -> tuple[str, bool]:
|
|
22
|
+
"""Return ``(value, expandable)`` for the text after ``=``.
|
|
23
|
+
|
|
24
|
+
Raises ValueError with a reason that never quotes the value.
|
|
25
|
+
"""
|
|
26
|
+
text = rest.lstrip()
|
|
27
|
+
if text.startswith(("'", '"')):
|
|
28
|
+
quote = text[0]
|
|
29
|
+
match = (_SINGLE if quote == "'" else _DOUBLE).match(text)
|
|
30
|
+
if match is None:
|
|
31
|
+
raise ValueError("unterminated quoted value (multi-line values are not supported)")
|
|
32
|
+
if _TAIL.fullmatch(text, match.end()) is None:
|
|
33
|
+
raise ValueError("unexpected text after the closing quote")
|
|
34
|
+
body = match.group(1)
|
|
35
|
+
if quote == "'":
|
|
36
|
+
if "${" in body:
|
|
37
|
+
raise ValueError(_AMBIGUOUS)
|
|
38
|
+
return _SINGLE_ESCAPES.sub(r"\1", body), False
|
|
39
|
+
return _DOUBLE_ESCAPES.sub(lambda m: _CONTROL.get(m.group(1), m.group(1)), body), True
|
|
40
|
+
return re.sub(r"\s+#.*", "", text).rstrip(), True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Expand:
|
|
44
|
+
"""Resolve ``${NAME}`` and ``${NAME:-default}`` against the file and an environment.
|
|
45
|
+
|
|
46
|
+
``file_first`` decides who wins when a name exists in both: the earlier file
|
|
47
|
+
entries (``dotenv_values`` behaviour) or the environment (``load_dotenv`` without
|
|
48
|
+
``override``). An unknown name with no default expands to ``""``; the default is
|
|
49
|
+
used only when the name is unset, never when it is set to ``""``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, values: Mapping[str, str], environ: Mapping[str, str], file_first: bool):
|
|
53
|
+
self._first, self._second = (values, environ) if file_first else (environ, values)
|
|
54
|
+
|
|
55
|
+
def lookup(self, name: str) -> str | None:
|
|
56
|
+
value = self._first.get(name)
|
|
57
|
+
return value if value is not None else self._second.get(name)
|
|
58
|
+
|
|
59
|
+
def expand(self, raw: str) -> str:
|
|
60
|
+
if "${" not in raw:
|
|
61
|
+
return raw
|
|
62
|
+
if raw.count("${") != len(_REFERENCE.findall(raw)):
|
|
63
|
+
raise ValueError("unterminated ${ reference")
|
|
64
|
+
return _REFERENCE.sub(self._replace, raw)
|
|
65
|
+
|
|
66
|
+
def _replace(self, match: re.Match[str]) -> str:
|
|
67
|
+
inner = _PLAIN.fullmatch(match.group(1))
|
|
68
|
+
if inner is None:
|
|
69
|
+
raise ValueError("only ${NAME} and ${NAME:-default} references are supported")
|
|
70
|
+
value = self.lookup(inner.group(1))
|
|
71
|
+
if value is not None:
|
|
72
|
+
return value
|
|
73
|
+
return inner.group(2) or ""
|
envread/_duration.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Parse human durations such as ``90``, ``250ms``, ``1h30m`` or ``1.5h``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from ._errors import InvalidEnvError
|
|
9
|
+
|
|
10
|
+
_UNITS = {
|
|
11
|
+
"ms": 0.001,
|
|
12
|
+
"s": 1.0,
|
|
13
|
+
"m": 60.0,
|
|
14
|
+
"h": 3600.0,
|
|
15
|
+
"d": 86400.0,
|
|
16
|
+
"w": 604800.0,
|
|
17
|
+
}
|
|
18
|
+
_PART = re.compile(r"(\d+(?:\.\d+)?|\.\d+)(ms|s|m|h|d|w)")
|
|
19
|
+
_REASON = "expected seconds (90) or amounts with units ms, s, m, h, d, w (1h30m, 250ms)"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_duration(name: str, raw: str) -> timedelta:
|
|
23
|
+
"""Return a non-negative timedelta.
|
|
24
|
+
|
|
25
|
+
A bare number is seconds. Otherwise the value is one or more ``<number><unit>``
|
|
26
|
+
parts with no spaces between them, each unit used at most once and from the
|
|
27
|
+
largest to the smallest, so ``1h30m`` is valid and ``30m1h`` is not.
|
|
28
|
+
"""
|
|
29
|
+
text = raw.strip().lower()
|
|
30
|
+
try:
|
|
31
|
+
return timedelta(seconds=float(text)) if _is_number(text) else _parse_parts(text)
|
|
32
|
+
except (ValueError, OverflowError):
|
|
33
|
+
raise InvalidEnvError(name, raw, "duration", _REASON) from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_number(text: str) -> bool:
|
|
37
|
+
return re.fullmatch(r"\d+(?:\.\d+)?|\.\d+", text) is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_parts(text: str) -> timedelta:
|
|
41
|
+
if not text:
|
|
42
|
+
raise ValueError("empty")
|
|
43
|
+
order = list(_UNITS)[::-1]
|
|
44
|
+
position = 0
|
|
45
|
+
last_rank = -1
|
|
46
|
+
seconds = 0.0
|
|
47
|
+
while position < len(text):
|
|
48
|
+
match = _PART.match(text, position)
|
|
49
|
+
if match is None:
|
|
50
|
+
raise ValueError("bad part")
|
|
51
|
+
rank = order.index(match.group(2))
|
|
52
|
+
if rank <= last_rank:
|
|
53
|
+
raise ValueError("units out of order")
|
|
54
|
+
last_rank = rank
|
|
55
|
+
seconds += float(match.group(1)) * _UNITS[match.group(2)]
|
|
56
|
+
position = match.end()
|
|
57
|
+
return timedelta(seconds=seconds)
|
envread/_env.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The Env reader: typed getters whose return type mypy infers from the call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from builtins import bool as _bool
|
|
6
|
+
from builtins import float as _float
|
|
7
|
+
from builtins import int as _int
|
|
8
|
+
from builtins import list as _list
|
|
9
|
+
from builtins import str as _str
|
|
10
|
+
from typing import Any, Literal, overload
|
|
11
|
+
|
|
12
|
+
from ._parsers import parse_bool, parse_float, parse_int, parse_json, parse_list, parse_str
|
|
13
|
+
from ._reader import MISSING
|
|
14
|
+
from ._rich import RichGetters
|
|
15
|
+
|
|
16
|
+
# The getters are named after the builtin types (env.int, env.str, ...), which shadows
|
|
17
|
+
# those names inside the class body, so annotations use these module-level aliases.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Env(RichGetters):
|
|
21
|
+
"""Read environment variables as typed values.
|
|
22
|
+
|
|
23
|
+
Each getter is overloaded so the static type follows the call: with no default
|
|
24
|
+
and no ``required=`` the result is non-optional and a missing variable raises;
|
|
25
|
+
passing ``None`` or ``required=False`` widens the result to include ``None``.
|
|
26
|
+
``secret=True`` keeps the value out of any error message.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@overload
|
|
30
|
+
def str(self, name: _str, *, secret: _bool = ...) -> _str: ...
|
|
31
|
+
@overload
|
|
32
|
+
def str(self, name: _str, default: _str, *, secret: _bool = ...) -> _str: ...
|
|
33
|
+
@overload
|
|
34
|
+
def str(self, name: _str, default: None, *, secret: _bool = ...) -> _str | None: ...
|
|
35
|
+
@overload
|
|
36
|
+
def str(self, name: _str, *, required: Literal[True], secret: _bool = ...) -> _str: ...
|
|
37
|
+
@overload
|
|
38
|
+
def str(self, name: _str, *, required: Literal[False], secret: _bool = ...) -> _str | None: ...
|
|
39
|
+
def str(
|
|
40
|
+
self, name: _str, default: Any = MISSING, *, required: Any = MISSING, secret: _bool = False
|
|
41
|
+
) -> _str | None:
|
|
42
|
+
"""Return the raw value; an empty value is returned as ``""``."""
|
|
43
|
+
return self._get(name, parse_str, default, required, secret)
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def int(self, name: _str, *, secret: _bool = ...) -> _int: ...
|
|
47
|
+
@overload
|
|
48
|
+
def int(self, name: _str, default: _int, *, secret: _bool = ...) -> _int: ...
|
|
49
|
+
@overload
|
|
50
|
+
def int(self, name: _str, default: None, *, secret: _bool = ...) -> _int | None: ...
|
|
51
|
+
@overload
|
|
52
|
+
def int(self, name: _str, *, required: Literal[True], secret: _bool = ...) -> _int: ...
|
|
53
|
+
@overload
|
|
54
|
+
def int(self, name: _str, *, required: Literal[False], secret: _bool = ...) -> _int | None: ...
|
|
55
|
+
def int(
|
|
56
|
+
self, name: _str, default: Any = MISSING, *, required: Any = MISSING, secret: _bool = False
|
|
57
|
+
) -> _int | None:
|
|
58
|
+
"""Parse what ``int()`` accepts in base 10 (``1_000`` too), ignoring whitespace."""
|
|
59
|
+
return self._get(name, parse_int, default, required, secret)
|
|
60
|
+
|
|
61
|
+
@overload
|
|
62
|
+
def float(self, name: _str, *, secret: _bool = ...) -> _float: ...
|
|
63
|
+
@overload
|
|
64
|
+
def float(self, name: _str, default: _float, *, secret: _bool = ...) -> _float: ...
|
|
65
|
+
@overload
|
|
66
|
+
def float(self, name: _str, default: None, *, secret: _bool = ...) -> _float | None: ...
|
|
67
|
+
@overload
|
|
68
|
+
def float(self, name: _str, *, required: Literal[True], secret: _bool = ...) -> _float: ...
|
|
69
|
+
@overload
|
|
70
|
+
def float(
|
|
71
|
+
self, name: _str, *, required: Literal[False], secret: _bool = ...
|
|
72
|
+
) -> _float | None: ...
|
|
73
|
+
def float(
|
|
74
|
+
self, name: _str, default: Any = MISSING, *, required: Any = MISSING, secret: _bool = False
|
|
75
|
+
) -> _float | None:
|
|
76
|
+
"""Parse anything Python's ``float()`` accepts, ignoring surrounding whitespace."""
|
|
77
|
+
return self._get(name, parse_float, default, required, secret)
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def bool(self, name: _str, *, secret: _bool = ...) -> _bool: ...
|
|
81
|
+
@overload
|
|
82
|
+
def bool(self, name: _str, default: _bool, *, secret: _bool = ...) -> _bool: ...
|
|
83
|
+
@overload
|
|
84
|
+
def bool(self, name: _str, default: None, *, secret: _bool = ...) -> _bool | None: ...
|
|
85
|
+
@overload
|
|
86
|
+
def bool(self, name: _str, *, required: Literal[True], secret: _bool = ...) -> _bool: ...
|
|
87
|
+
@overload
|
|
88
|
+
def bool(
|
|
89
|
+
self, name: _str, *, required: Literal[False], secret: _bool = ...
|
|
90
|
+
) -> _bool | None: ...
|
|
91
|
+
def bool(
|
|
92
|
+
self, name: _str, default: Any = MISSING, *, required: Any = MISSING, secret: _bool = False
|
|
93
|
+
) -> _bool | None:
|
|
94
|
+
"""Accept true/false, yes/no, on/off, 1/0, y/n, t/f, enable(d)/disable(d), any case."""
|
|
95
|
+
return self._get(name, parse_bool, default, required, secret)
|
|
96
|
+
|
|
97
|
+
@overload
|
|
98
|
+
def list(self, name: _str, *, separator: _str = ",", secret: _bool = ...) -> _list[_str]: ...
|
|
99
|
+
@overload
|
|
100
|
+
def list(
|
|
101
|
+
self, name: _str, default: _list[_str], *, separator: _str = ",", secret: _bool = ...
|
|
102
|
+
) -> _list[_str]: ...
|
|
103
|
+
@overload
|
|
104
|
+
def list(
|
|
105
|
+
self, name: _str, default: None, *, separator: _str = ",", secret: _bool = ...
|
|
106
|
+
) -> _list[_str] | None: ...
|
|
107
|
+
@overload
|
|
108
|
+
def list(
|
|
109
|
+
self, name: _str, *, required: Literal[True], separator: _str = ",", secret: _bool = ...
|
|
110
|
+
) -> _list[_str]: ...
|
|
111
|
+
@overload
|
|
112
|
+
def list(
|
|
113
|
+
self, name: _str, *, required: Literal[False], separator: _str = ",", secret: _bool = ...
|
|
114
|
+
) -> _list[_str] | None: ...
|
|
115
|
+
def list(
|
|
116
|
+
self,
|
|
117
|
+
name: _str,
|
|
118
|
+
default: Any = MISSING,
|
|
119
|
+
*,
|
|
120
|
+
required: Any = MISSING,
|
|
121
|
+
separator: _str = ",",
|
|
122
|
+
secret: _bool = False,
|
|
123
|
+
) -> _list[_str] | None:
|
|
124
|
+
"""Split on ``separator`` and trim each item; an empty value is ``[]``."""
|
|
125
|
+
return self._get(
|
|
126
|
+
name, lambda n, raw: parse_list(n, raw, separator), default, required, secret
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def json(
|
|
130
|
+
self, name: _str, default: Any = MISSING, *, required: Any = MISSING, secret: _bool = False
|
|
131
|
+
) -> Any:
|
|
132
|
+
"""Parse the value as JSON. Return type is Any, so annotate the call site."""
|
|
133
|
+
return self._get(name, parse_json, default, required, secret)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
env = Env()
|