envread 0.2.0__tar.gz
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-0.2.0/.gitattributes +1 -0
- envread-0.2.0/.github/FUNDING.yml +1 -0
- envread-0.2.0/.gitignore +12 -0
- envread-0.2.0/CHANGELOG.md +65 -0
- envread-0.2.0/CONTRIBUTING.md +23 -0
- envread-0.2.0/IMPROVEMENTS-round.md +87 -0
- envread-0.2.0/LICENSE +21 -0
- envread-0.2.0/PKG-INFO +269 -0
- envread-0.2.0/README.md +231 -0
- envread-0.2.0/pyproject.toml +69 -0
- envread-0.2.0/requirements-dev.txt +3 -0
- envread-0.2.0/scripts/check.ps1 +14 -0
- envread-0.2.0/scripts/release.ps1 +28 -0
- envread-0.2.0/src/envread/__init__.py +32 -0
- envread-0.2.0/src/envread/__main__.py +7 -0
- envread-0.2.0/src/envread/_check.py +68 -0
- envread-0.2.0/src/envread/_cli.py +152 -0
- envread-0.2.0/src/envread/_dotenv.py +105 -0
- envread-0.2.0/src/envread/_dotenv_value.py +73 -0
- envread-0.2.0/src/envread/_duration.py +57 -0
- envread-0.2.0/src/envread/_env.py +136 -0
- envread-0.2.0/src/envread/_errors.py +98 -0
- envread-0.2.0/src/envread/_parsers.py +114 -0
- envread-0.2.0/src/envread/_reader.py +114 -0
- envread-0.2.0/src/envread/_report.py +88 -0
- envread-0.2.0/src/envread/_rich.py +119 -0
- envread-0.2.0/src/envread/_secret.py +64 -0
- envread-0.2.0/src/envread/_settings.py +95 -0
- envread-0.2.0/src/envread/_specfile.py +78 -0
- envread-0.2.0/src/envread/_types.py +110 -0
- envread-0.2.0/src/envread/_version.py +3 -0
- envread-0.2.0/src/envread/py.typed +0 -0
- envread-0.2.0/tests/procs.py +49 -0
- envread-0.2.0/tests/test_cli.py +198 -0
- envread-0.2.0/tests/test_contract.py +200 -0
- envread-0.2.0/tests/test_dotenv.py +162 -0
- envread-0.2.0/tests/test_e2e_cli.py +151 -0
- envread-0.2.0/tests/test_edge_cases.py +75 -0
- envread-0.2.0/tests/test_env.py +124 -0
- envread-0.2.0/tests/test_process.py +147 -0
- envread-0.2.0/tests/test_reader.py +101 -0
- envread-0.2.0/tests/test_rich_getters.py +161 -0
- envread-0.2.0/tests/test_secret.py +56 -0
- envread-0.2.0/tests/test_settings.py +196 -0
- envread-0.2.0/tests/test_typing.py +93 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
buy_me_a_coffee: jlianesglrs
|
envread-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
- Renamed the distribution, import package and command to `envread` (the old name is not available on PyPI).
|
|
10
|
+
|
|
11
|
+
## [0.2.0]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `load(cls, source=None)` fills a plain dataclass from the environment: field name
|
|
16
|
+
upper-cased as the variable name, the annotation as the parser, a default,
|
|
17
|
+
`default_factory` or `X | None` making it optional. Every field is read first and all
|
|
18
|
+
failures are raised together as `EnvValidationError` (its `errors` tuple holds each one).
|
|
19
|
+
- `var(name, default=, default_factory=, separator=, secret=)`, a `field()` replacement
|
|
20
|
+
for fields that need a different name, a list separator or masking.
|
|
21
|
+
- Settings fields may be `str`, `int`, `float`, `bool`, `Decimal`, `Path`, `timedelta`,
|
|
22
|
+
`Secret`, any `Enum`, `Literal[...]` of strings, `dict[...]` (a JSON object), and
|
|
23
|
+
`list[X]` / `tuple[X, ...]` of a scalar type.
|
|
24
|
+
- New getters, each with the same typed overloads as the 0.1 ones: `decimal` (finite
|
|
25
|
+
only), `path`, `duration` (`90`, `250ms`, `1h30m`, `1.5h` into `timedelta`), `enum`
|
|
26
|
+
(member name in any case, or value) and `secret`.
|
|
27
|
+
- `secret=True` on every getter keeps the value out of the error message, its `value`
|
|
28
|
+
attribute and the exception chain. `Secret` masks itself in `str`, `repr` and
|
|
29
|
+
f-strings, compares in constant time and refuses pickling.
|
|
30
|
+
- `Env(prefix=...)` and `Env.prefixed()` scope names; errors name the full variable.
|
|
31
|
+
- `Env(empty_is_unset=True)` treats `KEY=` as unset; `Env.raw()`, `Env.is_set()`,
|
|
32
|
+
`in`, `Env.source`, `Env.full_name()` and a repr that never shows values.
|
|
33
|
+
- `.env` support as a documented subset of python-dotenv 1.x: `parse_dotenv`,
|
|
34
|
+
`read_dotenv` and `load_dotenv(path, override=False)`, raising `DotenvError` (file and
|
|
35
|
+
line, never the value) for anything outside the subset. On Windows, `load_dotenv`
|
|
36
|
+
refuses entries longer than the 32,767 characters the process environment accepts.
|
|
37
|
+
- `envread` command (also `python -m envread`): `check` validates a spec file or a
|
|
38
|
+
`module:Class` dataclass against the environment with text or JSON output, and `get`
|
|
39
|
+
prints one parsed value. Both take `--prefix`, `--env-file` and `--empty-is-unset`.
|
|
40
|
+
- `InvalidEnvError` gains `reason`, `secret`, `detail` and `masked()`.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- `bool` also accepts `enable`/`enabled` and `disable`/`disabled`, matching boolish.
|
|
45
|
+
- Very long values are trimmed in error messages; the `value` attribute keeps them whole.
|
|
46
|
+
- JSON errors report the line and column.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- `str(MissingEnvError(...))` no longer wraps the message in an extra pair of quotes.
|
|
51
|
+
|
|
52
|
+
## [0.1.0]
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- `Env` reader and a module-level `env` default, backed by `os.environ` or any mapping
|
|
57
|
+
you pass to `Env(source=...)`.
|
|
58
|
+
- Typed getters `str`, `int`, `float`, `bool`, `list` and `json`, each overloaded so the
|
|
59
|
+
return type follows the call: non-optional when required, `T | None` with a `None`
|
|
60
|
+
default or `required=False`.
|
|
61
|
+
- `bool` accepts `true/false`, `yes/no`, `on/off` and `1/0` in any case; `list` splits on
|
|
62
|
+
a configurable separator; `json` parses with the standard library.
|
|
63
|
+
- `MissingEnvError` (also a `KeyError`) and `InvalidEnvError` (also a `ValueError`), both
|
|
64
|
+
naming the offending variable.
|
|
65
|
+
- Ships `py.typed`, no runtime dependencies.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time. This project keeps a small, predictable shape.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install -r requirements-dev.txt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Before you open a pull request
|
|
12
|
+
|
|
13
|
+
- Keep public API in `src/envread/__init__.py`; internals live in `_`-prefixed modules.
|
|
14
|
+
- Add a test for the behaviour you change, and extend `tests/test_contract.py` when the
|
|
15
|
+
public API changes. Behaviour that depends on the operating system is tested in real
|
|
16
|
+
child processes with a crafted `env=` (see `tests/procs.py`), not with mocks.
|
|
17
|
+
- Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes. The end
|
|
18
|
+
to end test builds a wheel and installs it into a throwaway virtual environment; skip
|
|
19
|
+
it while iterating with `pytest -m "not e2e"`.
|
|
20
|
+
- Keep every source file at 200 lines or fewer.
|
|
21
|
+
- One logical change per commit, imperative subject line.
|
|
22
|
+
|
|
23
|
+
Open an issue first if the change is large or shifts the public API.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# envread improvement round
|
|
2
|
+
|
|
3
|
+
A ranked list of substantial, coherent improvements for this round. Every item is
|
|
4
|
+
additive: the 0.1 API (`Env`, `env`, the `str`/`int`/`float`/`bool`/`list`/`json`
|
|
5
|
+
getters, `EnvError`, `MissingEnvError`, `InvalidEnvError`) keeps its signatures, and
|
|
6
|
+
code written against 0.1 keeps working.
|
|
7
|
+
|
|
8
|
+
## Starting point
|
|
9
|
+
|
|
10
|
+
0.1 read one variable at a time with six overloaded getters, so `env.int("PORT")` was
|
|
11
|
+
`int` and `env.int("PORT", None)` was `int | None`. It stopped at the first problem,
|
|
12
|
+
quoted every bad value in its errors (secrets included), had no way to scope names,
|
|
13
|
+
no richer types, no settings object, no `.env` support and nothing to run from a
|
|
14
|
+
deployment script.
|
|
15
|
+
|
|
16
|
+
## Ranked improvements
|
|
17
|
+
|
|
18
|
+
1. **Collect every error at once.** Deployments fail one variable at a time when a
|
|
19
|
+
reader stops at the first problem. `load()` reads every field first and raises one
|
|
20
|
+
`EnvValidationError` whose `errors` tuple and message list every missing and invalid
|
|
21
|
+
variable together.
|
|
22
|
+
|
|
23
|
+
2. **A declarative settings class without metaclass magic.** `load(Settings)` fills a
|
|
24
|
+
plain (optionally frozen) dataclass: the field name upper-cased is the variable name,
|
|
25
|
+
the annotation picks the parser, a default or `X | None` makes it optional.
|
|
26
|
+
`var(name, default=, default_factory=, separator=, secret=)` is a drop-in for
|
|
27
|
+
`field()` when a field needs more. No base class, no decorator, no metaclass.
|
|
28
|
+
|
|
29
|
+
3. **Secrets never leak.** A `secret=True` keyword on every getter keeps the value out
|
|
30
|
+
of the message, the `value` attribute and the exception chain (no `__cause__` or
|
|
31
|
+
`__context__` that would print it in a traceback). A `Secret` type, returned by
|
|
32
|
+
`env.secret()` and usable as a field annotation, masks itself in `str`, `repr`,
|
|
33
|
+
f-strings and dataclass reprs, compares in constant time, and refuses pickling.
|
|
34
|
+
|
|
35
|
+
4. **Richer typed getters with precise overloads.** `decimal` (exact, finite only),
|
|
36
|
+
`path`, `duration` (`90`, `250ms`, `1h30m`, `1.5h` into `timedelta`), `enum` (member
|
|
37
|
+
by name in any case, or by value) and `secret`, each with the same five overloads as
|
|
38
|
+
the 0.1 getters so the static type follows the default and `required=`.
|
|
39
|
+
|
|
40
|
+
5. **Settings field types beyond scalars.** `list[X]` and `tuple[X, ...]` with a
|
|
41
|
+
per-field separator and typed items, `Literal["a", "b"]` choices, `Enum`
|
|
42
|
+
subclasses, `dict[...]` as a JSON object, `Secret`, plus every scalar getter type.
|
|
43
|
+
An annotation that cannot come from a string fails fast with `TypeError`.
|
|
44
|
+
|
|
45
|
+
6. **Prefix scoping.** `Env(prefix="APP_")` and `env.prefixed("DB_")` read
|
|
46
|
+
`APP_DB_HOST` from `host`; errors name the full variable, and `load()` honours the
|
|
47
|
+
reader's prefix.
|
|
48
|
+
|
|
49
|
+
7. **A deployment checker CLI.** `envread check env.spec` (or `module:Class` for a
|
|
50
|
+
dataclass) validates the current environment and prints one line per variable,
|
|
51
|
+
exits 0 or 1, supports `--format json`, `--prefix`, `--env-file`,
|
|
52
|
+
`--empty-is-unset`, `--quiet` and `--show-values` (secrets stay masked).
|
|
53
|
+
`envread get NAME --type int --default 8080` prints one canonical value for shell
|
|
54
|
+
scripts. Errors go to stderr with exit code 1.
|
|
55
|
+
|
|
56
|
+
8. **`.env` loading as a documented subset.** `parse_dotenv`, `read_dotenv` and
|
|
57
|
+
`load_dotenv` implement a subset of python-dotenv 1.x syntax and are tested line by
|
|
58
|
+
line against python-dotenv itself. Constructs the two common references
|
|
59
|
+
(python-dotenv and docker compose `env_file`) read differently, and anything outside
|
|
60
|
+
the subset, raise `DotenvError` naming the line instead of being guessed at.
|
|
61
|
+
|
|
62
|
+
9. **Platform facts made explicit.** Empty versus unset is now a choice
|
|
63
|
+
(`empty_is_unset=True`), `raw()` and `is_set()` expose presence, case sensitivity
|
|
64
|
+
follows the platform (documented and tested in real child processes), and
|
|
65
|
+
`load_dotenv` refuses an entry Windows cannot store (`NAME=value` over 32,767
|
|
66
|
+
characters) before writing anything.
|
|
67
|
+
|
|
68
|
+
10. **Boolean vocabulary aligned with boolish.** `bool` now also accepts
|
|
69
|
+
`enable`/`enabled` and `disable`/`disabled`, matching the default vocabulary of the
|
|
70
|
+
sibling library without depending on it.
|
|
71
|
+
|
|
72
|
+
11. **Error quality.** `MissingEnvError` no longer prints with doubled quotes, very
|
|
73
|
+
long values are trimmed in messages (the attribute keeps them whole), JSON errors
|
|
74
|
+
report line and column, and enum errors list the valid members.
|
|
75
|
+
|
|
76
|
+
12. **Ground-truth tests.** A contract test pins the complete promise of every public
|
|
77
|
+
name; real-process tests run child interpreters with a crafted `env=`; an end to end
|
|
78
|
+
test builds the wheel, installs it into a throwaway virtual environment and drives
|
|
79
|
+
the installed `envread` script.
|
|
80
|
+
|
|
81
|
+
## Deliberately left out
|
|
82
|
+
|
|
83
|
+
- Interpolation forms beyond `${NAME}` and `${NAME:-default}`, and multi-line quoted
|
|
84
|
+
`.env` values: the references disagree or the cost outweighs the use.
|
|
85
|
+
- ISO 8601 durations: the unit form covers configuration values without a parser for
|
|
86
|
+
a second grammar.
|
|
87
|
+
- `~` and variable expansion in paths: a config path should mean exactly what it says.
|
envread-0.2.0/LICENSE
ADDED
|
@@ -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.
|
envread-0.2.0/PKG-INFO
ADDED
|
@@ -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/
|