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.
Files changed (45) hide show
  1. envread-0.2.0/.gitattributes +1 -0
  2. envread-0.2.0/.github/FUNDING.yml +1 -0
  3. envread-0.2.0/.gitignore +12 -0
  4. envread-0.2.0/CHANGELOG.md +65 -0
  5. envread-0.2.0/CONTRIBUTING.md +23 -0
  6. envread-0.2.0/IMPROVEMENTS-round.md +87 -0
  7. envread-0.2.0/LICENSE +21 -0
  8. envread-0.2.0/PKG-INFO +269 -0
  9. envread-0.2.0/README.md +231 -0
  10. envread-0.2.0/pyproject.toml +69 -0
  11. envread-0.2.0/requirements-dev.txt +3 -0
  12. envread-0.2.0/scripts/check.ps1 +14 -0
  13. envread-0.2.0/scripts/release.ps1 +28 -0
  14. envread-0.2.0/src/envread/__init__.py +32 -0
  15. envread-0.2.0/src/envread/__main__.py +7 -0
  16. envread-0.2.0/src/envread/_check.py +68 -0
  17. envread-0.2.0/src/envread/_cli.py +152 -0
  18. envread-0.2.0/src/envread/_dotenv.py +105 -0
  19. envread-0.2.0/src/envread/_dotenv_value.py +73 -0
  20. envread-0.2.0/src/envread/_duration.py +57 -0
  21. envread-0.2.0/src/envread/_env.py +136 -0
  22. envread-0.2.0/src/envread/_errors.py +98 -0
  23. envread-0.2.0/src/envread/_parsers.py +114 -0
  24. envread-0.2.0/src/envread/_reader.py +114 -0
  25. envread-0.2.0/src/envread/_report.py +88 -0
  26. envread-0.2.0/src/envread/_rich.py +119 -0
  27. envread-0.2.0/src/envread/_secret.py +64 -0
  28. envread-0.2.0/src/envread/_settings.py +95 -0
  29. envread-0.2.0/src/envread/_specfile.py +78 -0
  30. envread-0.2.0/src/envread/_types.py +110 -0
  31. envread-0.2.0/src/envread/_version.py +3 -0
  32. envread-0.2.0/src/envread/py.typed +0 -0
  33. envread-0.2.0/tests/procs.py +49 -0
  34. envread-0.2.0/tests/test_cli.py +198 -0
  35. envread-0.2.0/tests/test_contract.py +200 -0
  36. envread-0.2.0/tests/test_dotenv.py +162 -0
  37. envread-0.2.0/tests/test_e2e_cli.py +151 -0
  38. envread-0.2.0/tests/test_edge_cases.py +75 -0
  39. envread-0.2.0/tests/test_env.py +124 -0
  40. envread-0.2.0/tests/test_process.py +147 -0
  41. envread-0.2.0/tests/test_reader.py +101 -0
  42. envread-0.2.0/tests/test_rich_getters.py +161 -0
  43. envread-0.2.0/tests/test_secret.py +56 -0
  44. envread-0.2.0/tests/test_settings.py +196 -0
  45. 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
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -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/