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/_specfile.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Read the plain-text spec format used by the ``envread check`` command.
|
|
2
|
+
|
|
3
|
+
One variable per line: ``NAME TYPE [optional] [secret]``. Blank lines and lines
|
|
4
|
+
starting with ``#`` are ignored. TYPE is one of ``str int float bool decimal path
|
|
5
|
+
duration json secret``, ``list`` or ``list:SEP`` (items split on SEP, comma by
|
|
6
|
+
default), or ``choice:A,B,C`` (exactly one of the listed words).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ._check import VarSpec
|
|
14
|
+
from ._parsers import parse_list
|
|
15
|
+
from ._types import NAMED, Parser, choice_parser, parse_secret
|
|
16
|
+
|
|
17
|
+
TYPE_HELP = "str, int, float, bool, decimal, path, duration, json, list[:SEP], choice:A,B"
|
|
18
|
+
_FLAGS = frozenset({"optional", "secret"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SpecError(ValueError):
|
|
22
|
+
"""A spec line or a ``--type`` value is malformed."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_type(token: str) -> tuple[Parser, str]:
|
|
26
|
+
"""Return the parser and display name for a type token such as ``list:;``."""
|
|
27
|
+
kind, _, argument = token.partition(":")
|
|
28
|
+
if kind == "list":
|
|
29
|
+
separator = argument or ","
|
|
30
|
+
return (lambda name, raw: parse_list(name, raw, separator)), "list"
|
|
31
|
+
if kind == "choice":
|
|
32
|
+
choices = tuple(word for word in argument.split(",") if word)
|
|
33
|
+
if not choices:
|
|
34
|
+
raise SpecError("choice needs at least one word, for example choice:dev,prod")
|
|
35
|
+
return choice_parser(choices), "choice"
|
|
36
|
+
if not argument and kind in NAMED:
|
|
37
|
+
return NAMED[kind], kind
|
|
38
|
+
raise SpecError(f"unknown type {token!r}; expected one of {TYPE_HELP}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_spec(text: str, source: str = "spec") -> list[VarSpec]:
|
|
42
|
+
"""Turn spec text into variable specs, raising SpecError naming the bad line."""
|
|
43
|
+
specs: list[VarSpec] = []
|
|
44
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
45
|
+
words = line.split()
|
|
46
|
+
if not words or words[0].startswith("#"):
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
specs.append(_line(words))
|
|
50
|
+
except SpecError as exc:
|
|
51
|
+
raise SpecError(f"{source}:{number}: {exc}") from None
|
|
52
|
+
return specs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _line(words: list[str]) -> VarSpec:
|
|
56
|
+
if len(words) < 2:
|
|
57
|
+
raise SpecError("expected NAME TYPE [optional] [secret]")
|
|
58
|
+
name, token, *flags = words
|
|
59
|
+
unknown = [flag for flag in flags if flag not in _FLAGS]
|
|
60
|
+
if unknown:
|
|
61
|
+
raise SpecError(f"unknown flag {unknown[0]!r}; expected optional or secret")
|
|
62
|
+
parser: Parser
|
|
63
|
+
if token == "secret":
|
|
64
|
+
parser, type_name = parse_secret, "secret"
|
|
65
|
+
else:
|
|
66
|
+
parser, type_name = parse_type(token)
|
|
67
|
+
return VarSpec(
|
|
68
|
+
name=name,
|
|
69
|
+
type_name=type_name,
|
|
70
|
+
parser=parser,
|
|
71
|
+
required="optional" not in flags,
|
|
72
|
+
secret="secret" in flags or token == "secret",
|
|
73
|
+
attr=name,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_spec(path: str) -> list[VarSpec]:
|
|
78
|
+
return parse_spec(Path(path).read_text(encoding="utf-8-sig"), path)
|
envread/_types.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Map a type annotation, or a type name from a spec file, to a parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import types
|
|
6
|
+
from collections.abc import Callable
|
|
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, Literal, Union, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
from ._duration import parse_duration
|
|
14
|
+
from ._errors import InvalidEnvError
|
|
15
|
+
from ._parsers import (
|
|
16
|
+
parse_bool,
|
|
17
|
+
parse_choice,
|
|
18
|
+
parse_decimal,
|
|
19
|
+
parse_enum,
|
|
20
|
+
parse_float,
|
|
21
|
+
parse_int,
|
|
22
|
+
parse_json,
|
|
23
|
+
parse_path,
|
|
24
|
+
parse_str,
|
|
25
|
+
split_list,
|
|
26
|
+
)
|
|
27
|
+
from ._secret import Secret
|
|
28
|
+
|
|
29
|
+
Parser = Callable[[str, str], Any]
|
|
30
|
+
|
|
31
|
+
SCALARS: dict[Any, tuple[Parser, str]] = {
|
|
32
|
+
str: (parse_str, "str"),
|
|
33
|
+
int: (parse_int, "int"),
|
|
34
|
+
float: (parse_float, "float"),
|
|
35
|
+
bool: (parse_bool, "bool"),
|
|
36
|
+
Decimal: (parse_decimal, "decimal"),
|
|
37
|
+
Path: (parse_path, "path"),
|
|
38
|
+
timedelta: (parse_duration, "duration"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
NAMED: dict[str, Parser] = {name: parser for parser, name in SCALARS.values()}
|
|
42
|
+
NAMED["json"] = parse_json
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_secret(name: str, raw: str) -> Secret:
|
|
46
|
+
return Secret(raw)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def split_parser(item: Parser, separator: str, kind: Callable[[list[Any]], Any]) -> Parser:
|
|
50
|
+
"""Build a parser that splits on ``separator`` and parses every item."""
|
|
51
|
+
|
|
52
|
+
def parse(name: str, raw: str) -> Any:
|
|
53
|
+
values = []
|
|
54
|
+
for index, part in enumerate(split_list(raw, separator), start=1):
|
|
55
|
+
try:
|
|
56
|
+
values.append(item(name, part))
|
|
57
|
+
except InvalidEnvError as exc:
|
|
58
|
+
# The reason must never quote the item: masking keeps the reason.
|
|
59
|
+
why = f"item {index} is not a valid {exc.type_name}"
|
|
60
|
+
why += f" ({exc.reason})" if exc.reason else ""
|
|
61
|
+
raise InvalidEnvError(name, raw, "list", why) from None
|
|
62
|
+
return kind(values)
|
|
63
|
+
|
|
64
|
+
return parse
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def choice_parser(choices: tuple[str, ...]) -> Parser:
|
|
68
|
+
return lambda name, raw: parse_choice(name, raw, choices)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _json_object(name: str, raw: str) -> dict[str, Any]:
|
|
72
|
+
value = parse_json(name, raw)
|
|
73
|
+
if not isinstance(value, dict):
|
|
74
|
+
raise InvalidEnvError(name, raw, "JSON object", "expected a JSON object")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
|
79
|
+
"""Return ``(inner, True)`` for ``X | None`` / ``Optional[X]``, else ``(annotation, False)``."""
|
|
80
|
+
if get_origin(annotation) in (Union, types.UnionType):
|
|
81
|
+
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
82
|
+
if len(args) == 1 and len(args) < len(get_args(annotation)):
|
|
83
|
+
return args[0], True
|
|
84
|
+
return annotation, False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve(annotation: Any, separator: str) -> tuple[Parser, str]:
|
|
88
|
+
"""Return the parser and a readable type name for a field annotation.
|
|
89
|
+
|
|
90
|
+
Raises TypeError for an annotation this package cannot read from a string.
|
|
91
|
+
"""
|
|
92
|
+
if annotation in SCALARS:
|
|
93
|
+
return SCALARS[annotation]
|
|
94
|
+
if annotation is Secret:
|
|
95
|
+
return parse_secret, "secret"
|
|
96
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
97
|
+
enum_type = annotation
|
|
98
|
+
return (lambda name, raw: parse_enum(name, raw, enum_type)), enum_type.__name__
|
|
99
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
100
|
+
if origin is Literal and args and all(isinstance(arg, str) for arg in args):
|
|
101
|
+
return choice_parser(tuple(args)), "choice"
|
|
102
|
+
if origin is dict or annotation is dict:
|
|
103
|
+
return _json_object, "JSON object"
|
|
104
|
+
if origin in (list, tuple) and args:
|
|
105
|
+
homogeneous = origin is list or (len(args) == 2 and args[1] is Ellipsis)
|
|
106
|
+
if homogeneous and args[0] in SCALARS:
|
|
107
|
+
item, item_name = SCALARS[args[0]]
|
|
108
|
+
kind: Callable[[list[Any]], Any] = list if origin is list else tuple
|
|
109
|
+
return split_parser(item, separator, kind), f"{origin.__name__}[{item_name}]"
|
|
110
|
+
raise TypeError(f"envread cannot read a field annotated {annotation!r} from a string")
|
envread/_version.py
ADDED
envread/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: envread
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Typed reader for environment variables: inferred return types, settings dataclasses, .env files and a checker CLI, zero dependencies
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/typedenv
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/typedenv/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/typedenv/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: 12factor,config,dotenv,env,environment,environment-variables,settings,typed
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: python-dotenv; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine; extra == 'dev'
|
|
36
|
+
Requires-Dist: typing-extensions; extra == 'dev'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# envread
|
|
40
|
+
|
|
41
|
+
Typed reader for environment variables with mypy-inferred return types, settings
|
|
42
|
+
dataclasses, `.env` files and a checker CLI. Zero dependencies.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install envread
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Read one variable
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from envread import env
|
|
54
|
+
|
|
55
|
+
database_url = env.str("DATABASE_URL") # str, raises MissingEnvError if unset
|
|
56
|
+
port = env.int("PORT", 8080) # int, the default when unset
|
|
57
|
+
workers = env.int("WORKERS", None) # int | None
|
|
58
|
+
debug = env.bool("DEBUG", False) # true/false, yes/no, on/off, 1/0, ...
|
|
59
|
+
hosts = env.list("ALLOWED_HOSTS") # split on "," and trimmed
|
|
60
|
+
timeout = env.duration("TIMEOUT", None) # "90", "250ms", "1h30m" -> timedelta
|
|
61
|
+
api_key = env.secret("API_KEY") # Secret: prints as ***
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every getter takes the same arguments and the static type follows the call:
|
|
65
|
+
|
|
66
|
+
| call | type | when unset |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `env.int("X")` | `int` | raises `MissingEnvError` |
|
|
69
|
+
| `env.int("X", required=True)` | `int` | raises `MissingEnvError` |
|
|
70
|
+
| `env.int("X", 8080)` | `int` | `8080` |
|
|
71
|
+
| `env.int("X", None)` | `int \| None` | `None` |
|
|
72
|
+
| `env.int("X", required=False)` | `int \| None` | `None` |
|
|
73
|
+
|
|
74
|
+
| getter | returns | accepts |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `str` | `str` | anything, returned as is (`""` stays `""`) |
|
|
77
|
+
| `int` | `int` | base-10 integer, surrounding whitespace ignored |
|
|
78
|
+
| `float` | `float` | anything `float()` accepts |
|
|
79
|
+
| `bool` | `bool` | `true/false`, `yes/no`, `on/off`, `1/0`, `y/n`, `t/f`, `enable(d)/disable(d)`, any case |
|
|
80
|
+
| `decimal` | `Decimal` | exact decimal; `NaN` and infinities are rejected |
|
|
81
|
+
| `path` | `Path` | non-empty path, taken literally (no `~` expansion) |
|
|
82
|
+
| `duration` | `timedelta` | seconds (`90`, `1.5`) or units `w d h m s ms` from largest to smallest (`1h30m`) |
|
|
83
|
+
| `enum` | your `Enum` | member name in any case, or the member value as text |
|
|
84
|
+
| `list` | `list[str]` | split on `separator=` (default `,`), items trimmed, `""` is `[]` |
|
|
85
|
+
| `json` | `Any` | JSON, parsed by the standard library |
|
|
86
|
+
| `secret` | `Secret` | anything, wrapped so it never prints |
|
|
87
|
+
|
|
88
|
+
The boolean words match the default vocabulary of
|
|
89
|
+
[boolish](https://pypi.org/project/boolish/), without depending on it.
|
|
90
|
+
|
|
91
|
+
## Load a settings dataclass
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from dataclasses import dataclass, field
|
|
95
|
+
from datetime import timedelta
|
|
96
|
+
from typing import Literal
|
|
97
|
+
|
|
98
|
+
from envread import Env, Secret, load, var
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Settings:
|
|
103
|
+
database_url: Secret # DATABASE_URL, required
|
|
104
|
+
port: int = 8080 # PORT
|
|
105
|
+
workers: int | None = None # WORKERS, optional
|
|
106
|
+
hosts: list[str] = field(default_factory=list)
|
|
107
|
+
timeout: timedelta = timedelta(seconds=5)
|
|
108
|
+
mode: Literal["dev", "prod"] = "dev"
|
|
109
|
+
token: str = var("SERVICE_TOKEN", default="", secret=True)
|
|
110
|
+
ports: tuple[int, ...] = var(default=(), separator=";")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
settings = load(Settings) # from os.environ
|
|
114
|
+
settings = load(Settings, Env(prefix="APP_")) # APP_DATABASE_URL, APP_PORT, ...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
There is no base class and no metaclass: `load` reads `dataclasses.fields()` and the
|
|
118
|
+
annotations. The variable name is the field name upper-cased, or `var(name=...)`. A
|
|
119
|
+
field with a default, a `default_factory` or an `X | None` annotation is optional.
|
|
120
|
+
Supported annotations: `str`, `int`, `float`, `bool`, `Decimal`, `Path`, `timedelta`,
|
|
121
|
+
`Secret`, any `Enum`, `Literal["a", "b"]`, `dict[...]` (a JSON object), and `list[X]` or
|
|
122
|
+
`tuple[X, ...]` of a scalar type. Anything else raises `TypeError` when `load` runs.
|
|
123
|
+
|
|
124
|
+
`load` reads every field before raising, so one run reports every problem:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
EnvValidationError: 3 environment variables failed validation:
|
|
128
|
+
- required environment variable 'DATABASE_URL' is not set
|
|
129
|
+
- environment variable 'PORT' is not a valid integer: 'eighty'
|
|
130
|
+
- environment variable 'MODE' is not a valid choice: 'staging': expected one of dev, prod
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The individual errors are in `error.errors`. If you lint with ruff, add
|
|
134
|
+
`extend-immutable-calls = ["envread.var"]` under `[tool.ruff.lint.flake8-bugbear]`
|
|
135
|
+
so `var()` is treated like `field()`.
|
|
136
|
+
|
|
137
|
+
## Secrets
|
|
138
|
+
|
|
139
|
+
`secret=True` on any getter, `var(secret=True)`, or a `Secret` annotation keeps the
|
|
140
|
+
value out of error messages, the error's `value` attribute (`"***"`) and the exception
|
|
141
|
+
chain, so even an uncaught traceback does not show it. A `Secret` prints as `***` in
|
|
142
|
+
`str`, `repr`, f-strings and dataclass reprs; call `.reveal()` where the real value is
|
|
143
|
+
needed. It compares in constant time and refuses to be pickled. `secret=True` alone only
|
|
144
|
+
protects errors; annotate the field as `Secret` to keep it out of the dataclass repr too.
|
|
145
|
+
|
|
146
|
+
## Prefixes, empty values and presence
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
app = Env(prefix="APP_") # app.int("PORT") reads APP_PORT
|
|
150
|
+
db = app.prefixed("DB_") # db.str("HOST") reads APP_DB_HOST
|
|
151
|
+
strict = Env(empty_is_unset=True) # KEY= counts as unset
|
|
152
|
+
env.raw("PORT") # the unparsed string, or None
|
|
153
|
+
env.is_set("PORT"), "PORT" in env # presence
|
|
154
|
+
cfg = Env({"PORT": "9090"}) # any mapping instead of os.environ
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Platform facts
|
|
158
|
+
|
|
159
|
+
- **Names**: `os.environ` is case-insensitive on Windows (keys are stored upper-case)
|
|
160
|
+
and case-sensitive on POSIX, so `env.str("path")` finds `PATH` on Windows only. A
|
|
161
|
+
plain `dict` source is always exact.
|
|
162
|
+
- **Values** are always strings; there is no `None` in the environment. A variable can
|
|
163
|
+
be set to `""`, which is different from unset unless you pass `empty_is_unset=True`.
|
|
164
|
+
- **Length**: on Windows `os.environ` refuses an entry whose `NAME=value` is longer
|
|
165
|
+
than 32,767 characters, although a child process can receive longer values through
|
|
166
|
+
`subprocess` `env=`. `load_dotenv` checks this up front and raises `DotenvError`.
|
|
167
|
+
|
|
168
|
+
## `.env` files
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from envread import load_dotenv, parse_dotenv, read_dotenv
|
|
172
|
+
|
|
173
|
+
load_dotenv(".env") # set what is not already set; returns what it set
|
|
174
|
+
load_dotenv(".env", override=True) # the file wins
|
|
175
|
+
values = read_dotenv(".env") # dict, environment untouched
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The supported syntax is a subset of python-dotenv 1.x, tested line by line against
|
|
179
|
+
python-dotenv itself:
|
|
180
|
+
|
|
181
|
+
| syntax | result |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| blank lines, `# comment` lines | ignored |
|
|
184
|
+
| `export KEY=value`, `KEY = value` | `export` and spaces around `=` are allowed |
|
|
185
|
+
| `KEY=plain value # note` | `plain value` (an inline `#` needs a space before it) |
|
|
186
|
+
| `KEY='literal \n $X'` | taken literally; only `\'` and `\\` are escapes |
|
|
187
|
+
| `KEY="a\tb ${X}"` | escapes `\\ \' \" \a \b \f \n \r \t \v`, then expansion |
|
|
188
|
+
| `${NAME}`, `${NAME:-default}` | earlier keys, then the environment; unknown is `""` |
|
|
189
|
+
| `KEY` (no `=`) | skipped |
|
|
190
|
+
| `KEY=` | `""` |
|
|
191
|
+
|
|
192
|
+
Expansion order follows python-dotenv: `read_dotenv` and `parse_dotenv` prefer earlier
|
|
193
|
+
keys in the file, `load_dotenv` prefers the existing environment unless `override=True`.
|
|
194
|
+
The default in `${NAME:-default}` applies only when `NAME` is unset. Unquoted `$NAME`
|
|
195
|
+
without braces is literal.
|
|
196
|
+
|
|
197
|
+
Anything else raises `DotenvError` with the file and line, never the value:
|
|
198
|
+
multi-line quoted values, other `${...}` forms (`${X-d}`, `${X:?err}`, nesting), text
|
|
199
|
+
after a closing quote, and `${` inside single quotes (python-dotenv expands it, docker
|
|
200
|
+
compose keeps it literal). Docker compose `env_file` also expands unbraced `$NAME`
|
|
201
|
+
and treats `${NAME:-default}` as "unset or empty", so files that rely on those
|
|
202
|
+
compose-only behaviours read differently there.
|
|
203
|
+
|
|
204
|
+
## Command line
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
envread check env.spec validate every variable in a spec file
|
|
208
|
+
envread check myapp.settings:Settings validate the fields of a dataclass
|
|
209
|
+
envread check env.spec --env-file .env layer a .env file under the environment
|
|
210
|
+
envread check env.spec --format json machine-readable report
|
|
211
|
+
envread get PORT --type int --default 8080 print one parsed value
|
|
212
|
+
envread get DEBUG --type bool prints true or false
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
A spec file lists one variable per line as `NAME TYPE [optional] [secret]`, with types
|
|
216
|
+
`str int float bool decimal path duration json secret`, `list[:SEP]` and
|
|
217
|
+
`choice:A,B,C`:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
# deployment contract
|
|
221
|
+
DATABASE_URL secret
|
|
222
|
+
PORT int optional
|
|
223
|
+
LOG_LEVEL choice:debug,info,warning
|
|
224
|
+
HOSTS list:;
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
$ envread check env.spec
|
|
229
|
+
ok DATABASE_URL secret
|
|
230
|
+
invalid PORT int, optional: not a valid integer: 'eighty'
|
|
231
|
+
missing LOG_LEVEL choice: not set
|
|
232
|
+
ok HOSTS list
|
|
233
|
+
envread: 2 of 4 variables failed
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`check` exits 0 when every variable is valid and 1 otherwise, so it gates a deploy
|
|
237
|
+
script directly. Values are printed only with `--show-values`, and secrets never are.
|
|
238
|
+
`--prefix`, `--env-file` and `--empty-is-unset` work on both commands; `--quiet` gives
|
|
239
|
+
an exit code only. Errors go to stderr with exit code 1. `python -m envread` works the same
|
|
240
|
+
as the `envread` script.
|
|
241
|
+
|
|
242
|
+
## Errors
|
|
243
|
+
|
|
244
|
+
`MissingEnvError` is also a `KeyError`; `InvalidEnvError`, `EnvValidationError` and
|
|
245
|
+
`DotenvError` are also `ValueError`s; all of them derive from `EnvError`.
|
|
246
|
+
|
|
247
|
+
## Why
|
|
248
|
+
|
|
249
|
+
Reading config from the environment is a one-liner until you want the value typed, a
|
|
250
|
+
missing variable to fail loudly, every failure reported in one go, secrets kept out of
|
|
251
|
+
logs, and your type checker to agree with all of it. The existing options each give up
|
|
252
|
+
one of those or pull in dependencies: `environs` needs `marshmallow` and
|
|
253
|
+
`python-dotenv`, `environ-config` is built on `attrs`, `pydantic-settings` pulls in
|
|
254
|
+
`pydantic-core`, and `typenv` depends on `python-dotenv` and has not shipped since 2021.
|
|
255
|
+
|
|
256
|
+
`envread` has no runtime dependencies, ships `py.typed`, and uses `@overload` so
|
|
257
|
+
`env.int("X")` is `int` and `env.int("X", None)` is `int | None` with no casts. Reach
|
|
258
|
+
for it when you want typed environment access and a deploy-time check without adding a
|
|
259
|
+
settings framework to the project.
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT. See [LICENSE](LICENSE).
|
|
264
|
+
|
|
265
|
+
## Links
|
|
266
|
+
|
|
267
|
+
- Website: https://lianes8studio.com
|
|
268
|
+
- GitHub: https://github.com/JaviLianes8
|
|
269
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
envread/__init__.py,sha256=d3_0iA2PakvMLNMgP9dHTaAbYGdrpX0S7TiYtCPHfKk,683
|
|
2
|
+
envread/__main__.py,sha256=pnI90LA6TY7WQCY_EKn_kWt1tAbMSmCN253VTkiqT0A,129
|
|
3
|
+
envread/_check.py,sha256=Bd5gjZF4OZkrg3W5tvqtZVwC6Z7v5u8C3PpfNXRjviI,2129
|
|
4
|
+
envread/_cli.py,sha256=5pnqJ-0s7a_29earM3Lfk-LBG_d6CS3XMtKnWTHmloA,6036
|
|
5
|
+
envread/_dotenv.py,sha256=aU8K5lbaXrW-fNVj7JIewKPZElc04k7qSxvC7vKnBKc,4534
|
|
6
|
+
envread/_dotenv_value.py,sha256=emPcCBbDbt_uNPLcujFk7hTFbj9yjsJFuYayPMDJo8Q,3158
|
|
7
|
+
envread/_duration.py,sha256=l33Dxkf8vxLabzY8zW-HpWhCr8XR0JAoF6-feWXPlLE,1750
|
|
8
|
+
envread/_env.py,sha256=0RReXtrg4nUgW8tF6fWBPoP3ZGg88iIpK0vv8m-wyvY,5740
|
|
9
|
+
envread/_errors.py,sha256=224yi3Jd6bKsnuAWLlACNenBMqp8zioTwMN8Xn5ocq8,3431
|
|
10
|
+
envread/_parsers.py,sha256=6pWdk1ogPRGLkidtCsnJ3aL9d5e6Y4R_FdUaO7IOZ9E,3633
|
|
11
|
+
envread/_reader.py,sha256=WJptZApgKTDVmZV6rvMHE3gdvPqBTW6K7vkoCVr_NQI,3800
|
|
12
|
+
envread/_report.py,sha256=zgXQPRKRIBFQHEoCRLayjWhCATjwNPDn7REpTuEShxs,3090
|
|
13
|
+
envread/_rich.py,sha256=cma2krJ7XIEgl_qpZnuZIkIZg9TFLbdL4UPRK8caDrA,4886
|
|
14
|
+
envread/_secret.py,sha256=TU6DoAxmQHC6adAj_WRwzjvBJtQzfDEa58X5cBc6yoI,1849
|
|
15
|
+
envread/_settings.py,sha256=Y8QHXRzkJT5UMaS_IlxLGixWNaUlWp9gnfF216tNeXM,3818
|
|
16
|
+
envread/_specfile.py,sha256=UFeXC45A64GjKcEW7aABLVtqjEO9NmTlElGVvdAN_8w,2838
|
|
17
|
+
envread/_types.py,sha256=9hWs44aK1bTE06wocc2C3Y6-HiJFOgg9mcvsfqEP5OE,3972
|
|
18
|
+
envread/_version.py,sha256=qma9hy2PH3uT1-7KbTvB6thSsXLqsU_hMKESUJVJ_H0,77
|
|
19
|
+
envread/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
envread-0.2.0.dist-info/METADATA,sha256=0u93WcxEAPONL7ka48bdtCh-thJX-zzbRwxtKsoHgkA,11513
|
|
21
|
+
envread-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
22
|
+
envread-0.2.0.dist-info/entry_points.txt,sha256=sPiHHk_I5nSu7WCjzVHD5COWk22LUYHeuvHZ2p2q21E,46
|
|
23
|
+
envread-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
24
|
+
envread-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
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.
|