xtr-dotenv 1.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.
- xtr_dotenv-1.2.0/LICENSE +21 -0
- xtr_dotenv-1.2.0/PKG-INFO +248 -0
- xtr_dotenv-1.2.0/README.md +223 -0
- xtr_dotenv-1.2.0/pyproject.toml +153 -0
- xtr_dotenv-1.2.0/pyproject.toml.orig +154 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/__init__.py +49 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/bundle/__init__.py +8 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/bundle/dotenv_bundle.py +52 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/bundle/dotenv_config.py +61 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/command/__init__.py +8 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/command/debug_dotenv_command.py +90 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/command/dotenv_dump_command.py +70 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/dotenv.py +467 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/dotenv_settings.py +67 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/dotenv_settings_source.py +95 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/exception/__init__.py +21 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/exception/dotenv_error.py +14 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/exception/format_error.py +35 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/exception/path_error.py +29 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/exception/variable_circular_reference_error.py +26 -0
- xtr_dotenv-1.2.0/src/xtr_dotenv/py.typed +0 -0
xtr_dotenv-1.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
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.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-dotenv
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Layered .env files loaded into the environment, and the same layers behind a typed settings model.
|
|
5
|
+
Keywords: dotenv,env,settings,configuration,layered
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: pydantic-settings>=2.4,<3
|
|
18
|
+
Requires-Dist: python-dotenv>=1.0,<2
|
|
19
|
+
Requires-Dist: xtr-console>=1.0,<2 ; extra == 'console'
|
|
20
|
+
Requires-Dist: xtr-dependency-injection>=1.0,<2 ; extra == 'di'
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: console
|
|
23
|
+
Provides-Extra: di
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<div align="center">
|
|
27
|
+
|
|
28
|
+
# xtr-dotenv
|
|
29
|
+
|
|
30
|
+
**Layered `.env` files loaded into the environment, and the same layers behind a typed settings model.**
|
|
31
|
+
|
|
32
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
33
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
34
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Why?
|
|
41
|
+
|
|
42
|
+
Real applications keep several `.env` files layered so a developer laptop, a CI runner and a
|
|
43
|
+
production host each end up with the settings that suit, without any one file knowing about
|
|
44
|
+
the other. This library is the loader every layer runs through, plus a pydantic-settings
|
|
45
|
+
source that feeds a typed model from the same cascade โ without ever touching
|
|
46
|
+
`os.environ` behind your back.
|
|
47
|
+
|
|
48
|
+
- ๐ **The layered cascade** โ `.env` โ `.env.local` โ `.env.{env}` โ `.env.{env}.local`, with a
|
|
49
|
+
`.env.dist` read instead of a missing `.env`, and real environment variables always winning.
|
|
50
|
+
- ๐งช **Testable end-to-end** โ hand the loader a plain `dict` and no other test in the same
|
|
51
|
+
process can see anything it did.
|
|
52
|
+
- ๐งฉ **Pydantic-settings integration** โ `DotenvSettings` reads the same cascade from a
|
|
53
|
+
sandboxed copy of `os.environ`, so a model gets the values without side effects.
|
|
54
|
+
- ๐ **Variable expansion** โ `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR:=default}`, `\$` as
|
|
55
|
+
a literal. Single-quoted values are literal too. `$(command)` is kept as text, never run.
|
|
56
|
+
- โก **A dumped fast path** โ `dotenv:dump` writes the compiled cascade to `.env.local.json`;
|
|
57
|
+
`boot_env` reads that one file instead of the cascade.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
uv add xtr-dotenv # the loader and the settings source
|
|
63
|
+
uv add "xtr-dotenv[di]" # + a DotenvBundle for xtr-dependency-injection
|
|
64
|
+
uv add "xtr-dotenv[console]" # + the dotenv:dump and debug:dotenv commands
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires Python 3.11+.
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
Call `Dotenv().boot_env(...)` at the very top of your entry point โ **before** anything
|
|
72
|
+
reads a settings model, and before you build a kernel:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
|
|
77
|
+
from xtr_dotenv import Dotenv
|
|
78
|
+
|
|
79
|
+
Dotenv().boot_env(str(Path(__file__).parent / ".env"))
|
|
80
|
+
|
|
81
|
+
# ... only now import the kernel, settings, etc.
|
|
82
|
+
from app.kernel import kernel
|
|
83
|
+
|
|
84
|
+
raise SystemExit(kernel.run(...))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The loader is stateless: hand it a `MutableMapping[str, str]` as `environ=` and no other
|
|
88
|
+
code sees a thing.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
sandbox: dict[str, str] = {}
|
|
92
|
+
Dotenv(environ=sandbox).load_env("/etc/app/.env")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## The file cascade
|
|
96
|
+
|
|
97
|
+
`load_env(path)` reads, in order:
|
|
98
|
+
|
|
99
|
+
| Order | File | When it applies |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| 1 | `path` | Always, or `path.dist` when `path` is missing |
|
|
102
|
+
| 2 | *(reads env key from environ; defaults to `default_env`)* | |
|
|
103
|
+
| 3 | `path.local` | Only outside `test_envs` โ a developer's per-machine overrides |
|
|
104
|
+
| 4 | *(re-reads env key)* | The `.local` file may have set a different environment |
|
|
105
|
+
| 5 | `path.{env}` | When `env` is not `"local"` |
|
|
106
|
+
| 6 | `path.{env}.local` | Same โ a per-machine override for that environment |
|
|
107
|
+
|
|
108
|
+
Later files override earlier ones. A **real** environment variable always wins over any file
|
|
109
|
+
unless `override_existing_vars=True` is passed. `XTR_DOTENV_VARS` tracks which names came
|
|
110
|
+
from a file so a later file may replace them; `XTR_DOTENV_PATH` records the base path so
|
|
111
|
+
tooling can find it.
|
|
112
|
+
|
|
113
|
+
## Variable expansion
|
|
114
|
+
|
|
115
|
+
| Written | Means |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `$VAR`, `${VAR}` | the value of `VAR`, or empty when unset |
|
|
118
|
+
| `${VAR:-default}` | `default` when `VAR` is unset or empty |
|
|
119
|
+
| `${VAR:=default}` | the same, and record `VAR=default` for later lookups |
|
|
120
|
+
| `\$` | a literal dollar sign |
|
|
121
|
+
|
|
122
|
+
**Single-quoted values are never expanded** โ that is the escape hatch for a literal `$`
|
|
123
|
+
inside a password. `$(command)` is kept as text and never run: a settings loader should do
|
|
124
|
+
no I/O beyond reading files. A default is plain text โ a quote, a brace or a `$` in one
|
|
125
|
+
(`${A:-${B}}`), or a `${` never closed, is a `FormatError` naming the line. A value spanning
|
|
126
|
+
lines must be quoted.
|
|
127
|
+
|
|
128
|
+
Expansion runs once every file of the cascade is read, so a value in `.env` may reference
|
|
129
|
+
one only `.env.local` defines; a real environment variable wins inside an expansion as it
|
|
130
|
+
does everywhere else. A self-referencing `A=${A:-x}` reads the value `A` has now (or the
|
|
131
|
+
default) โ it does not cycle. Anything still unresolved after five passes is a real cycle and
|
|
132
|
+
raises `VariableCircularReferenceError`.
|
|
133
|
+
|
|
134
|
+
## Typed settings from the cascade
|
|
135
|
+
|
|
136
|
+
`DotenvSettings` is a `BaseSettings` base whose subclass reads the layered cascade from a
|
|
137
|
+
sandboxed copy of `os.environ`:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from typing import ClassVar
|
|
141
|
+
|
|
142
|
+
from xtr_dotenv import DotenvSettings
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AppSettings(DotenvSettings):
|
|
146
|
+
_dotenv_path: ClassVar[str] = "/etc/app/.env"
|
|
147
|
+
_dotenv_env_key: ClassVar[str] = "APP_ENV"
|
|
148
|
+
_dotenv_default_env: ClassVar[str] = "prod"
|
|
149
|
+
|
|
150
|
+
database_url: str
|
|
151
|
+
log_level: str = "INFO"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Source priority is: init keyword arguments > real environment variables > the dotenv
|
|
155
|
+
cascade > secret files > field defaults. **No file the source reads writes into
|
|
156
|
+
`os.environ`.**
|
|
157
|
+
|
|
158
|
+
## Errors
|
|
159
|
+
|
|
160
|
+
Everything the library raises derives from `DotenvError`, and carries the data as typed
|
|
161
|
+
attributes.
|
|
162
|
+
|
|
163
|
+
| Error | Raised when |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `FormatError` | A line cannot be parsed (bad binding, key without `=`); carries `.path`, `.line`, `.reason` |
|
|
166
|
+
| `PathError` | A file cannot be read; carries `.path` |
|
|
167
|
+
| `VariableCircularReferenceError` | Variables reference each other and never resolve; carries `.names` |
|
|
168
|
+
|
|
169
|
+
`FormatError` and `VariableCircularReferenceError` are also `ValueError`s; `PathError` is
|
|
170
|
+
also `OSError`. Existing `except` blocks keep working.
|
|
171
|
+
|
|
172
|
+
## Kernel / bundle
|
|
173
|
+
|
|
174
|
+
An application using [xtr-dependency-injection](../xtr-dependency-injection) lists
|
|
175
|
+
`DotenvBundle` in its `app/bundles.py`. **The bundle does not load `.env` files** โ
|
|
176
|
+
`Dotenv().boot_env(...)` runs before the kernel is built. What the bundle contributes is
|
|
177
|
+
the two commands that need the kernel to know its project directory:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
uv add "xtr-dotenv[di,console]"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
# app/bundles.py
|
|
185
|
+
from xtr_dotenv.bundle import DotenvBundle
|
|
186
|
+
|
|
187
|
+
BUNDLES = {DotenvBundle: {"all": True}}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
# app/config/dotenv.py
|
|
192
|
+
from xtr_dependency_injection import configure
|
|
193
|
+
from xtr_dotenv.bundle import DotenvConfig
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@configure
|
|
197
|
+
def dotenv() -> DotenvConfig:
|
|
198
|
+
return DotenvConfig(path="%kernel.project_dir%/.env", env_key="APP_ENV")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
| `DotenvConfig` field | Meaning |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
| `path` | The base `.env` path; default `%kernel.project_dir%/.env` โ parameter references are resolved by the kernel, and a relative path is taken from the project directory |
|
|
204
|
+
| `env_key` | The variable naming the active environment |
|
|
205
|
+
| `debug_key` | The variable naming debug mode |
|
|
206
|
+
| `test_envs` | Environments where the `.local` overlay is skipped |
|
|
207
|
+
| `prod_envs` | Environments considered production, used for debug defaulting |
|
|
208
|
+
|
|
209
|
+
### Commands
|
|
210
|
+
|
|
211
|
+
| Command | What it does |
|
|
212
|
+
| --- | --- |
|
|
213
|
+
| `dotenv:dump [env]` | Compile the cascade for `env` (default: the kernel's env) into `<path>.local.json`. Runs on a fresh environ with only the env key, so real secrets never land in the dump |
|
|
214
|
+
| `debug:dotenv [name]` | List the files that apply in cascade order (loaded / missing) and each variable's value per file, filtered by `name` when given |
|
|
215
|
+
|
|
216
|
+
The commands are only registered when the console bundle is active; on a headless
|
|
217
|
+
application the bundle is still valid and boots at zero config.
|
|
218
|
+
|
|
219
|
+
## Layout
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
xtr_dotenv/
|
|
223
|
+
โโโ dotenv.py the loader: parse, load, overload, populate, load_env, boot_env
|
|
224
|
+
โโโ dotenv_settings_source.py a pydantic-settings source that runs the cascade in a sandbox
|
|
225
|
+
โโโ dotenv_settings.py BaseSettings base declaring the cascade behind the real env
|
|
226
|
+
โโโ exception/ DotenvError + FormatError, PathError, VariableCircularReferenceError
|
|
227
|
+
โโโ command/ dotenv:dump and debug:dotenv (xtr-console commands)
|
|
228
|
+
โโโ bundle/ DotenvBundle for xtr-dependency-injection
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
234
|
+
`packages/xtr-dotenv`; run the commands below from there. The `python-xtr-dotenv`
|
|
235
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
uv sync --all-extras
|
|
239
|
+
uv run ruff check
|
|
240
|
+
uv run ruff format --check
|
|
241
|
+
uv run basedpyright
|
|
242
|
+
uv run ty check
|
|
243
|
+
uv run pytest
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT โ see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-dotenv
|
|
4
|
+
|
|
5
|
+
**Layered `.env` files loaded into the environment, and the same layers behind a typed settings model.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
9
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
Real applications keep several `.env` files layered so a developer laptop, a CI runner and a
|
|
18
|
+
production host each end up with the settings that suit, without any one file knowing about
|
|
19
|
+
the other. This library is the loader every layer runs through, plus a pydantic-settings
|
|
20
|
+
source that feeds a typed model from the same cascade โ without ever touching
|
|
21
|
+
`os.environ` behind your back.
|
|
22
|
+
|
|
23
|
+
- ๐ **The layered cascade** โ `.env` โ `.env.local` โ `.env.{env}` โ `.env.{env}.local`, with a
|
|
24
|
+
`.env.dist` read instead of a missing `.env`, and real environment variables always winning.
|
|
25
|
+
- ๐งช **Testable end-to-end** โ hand the loader a plain `dict` and no other test in the same
|
|
26
|
+
process can see anything it did.
|
|
27
|
+
- ๐งฉ **Pydantic-settings integration** โ `DotenvSettings` reads the same cascade from a
|
|
28
|
+
sandboxed copy of `os.environ`, so a model gets the values without side effects.
|
|
29
|
+
- ๐ **Variable expansion** โ `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR:=default}`, `\$` as
|
|
30
|
+
a literal. Single-quoted values are literal too. `$(command)` is kept as text, never run.
|
|
31
|
+
- โก **A dumped fast path** โ `dotenv:dump` writes the compiled cascade to `.env.local.json`;
|
|
32
|
+
`boot_env` reads that one file instead of the cascade.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv add xtr-dotenv # the loader and the settings source
|
|
38
|
+
uv add "xtr-dotenv[di]" # + a DotenvBundle for xtr-dependency-injection
|
|
39
|
+
uv add "xtr-dotenv[console]" # + the dotenv:dump and debug:dotenv commands
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.11+.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Call `Dotenv().boot_env(...)` at the very top of your entry point โ **before** anything
|
|
47
|
+
reads a settings model, and before you build a kernel:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from xtr_dotenv import Dotenv
|
|
53
|
+
|
|
54
|
+
Dotenv().boot_env(str(Path(__file__).parent / ".env"))
|
|
55
|
+
|
|
56
|
+
# ... only now import the kernel, settings, etc.
|
|
57
|
+
from app.kernel import kernel
|
|
58
|
+
|
|
59
|
+
raise SystemExit(kernel.run(...))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The loader is stateless: hand it a `MutableMapping[str, str]` as `environ=` and no other
|
|
63
|
+
code sees a thing.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
sandbox: dict[str, str] = {}
|
|
67
|
+
Dotenv(environ=sandbox).load_env("/etc/app/.env")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## The file cascade
|
|
71
|
+
|
|
72
|
+
`load_env(path)` reads, in order:
|
|
73
|
+
|
|
74
|
+
| Order | File | When it applies |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| 1 | `path` | Always, or `path.dist` when `path` is missing |
|
|
77
|
+
| 2 | *(reads env key from environ; defaults to `default_env`)* | |
|
|
78
|
+
| 3 | `path.local` | Only outside `test_envs` โ a developer's per-machine overrides |
|
|
79
|
+
| 4 | *(re-reads env key)* | The `.local` file may have set a different environment |
|
|
80
|
+
| 5 | `path.{env}` | When `env` is not `"local"` |
|
|
81
|
+
| 6 | `path.{env}.local` | Same โ a per-machine override for that environment |
|
|
82
|
+
|
|
83
|
+
Later files override earlier ones. A **real** environment variable always wins over any file
|
|
84
|
+
unless `override_existing_vars=True` is passed. `XTR_DOTENV_VARS` tracks which names came
|
|
85
|
+
from a file so a later file may replace them; `XTR_DOTENV_PATH` records the base path so
|
|
86
|
+
tooling can find it.
|
|
87
|
+
|
|
88
|
+
## Variable expansion
|
|
89
|
+
|
|
90
|
+
| Written | Means |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `$VAR`, `${VAR}` | the value of `VAR`, or empty when unset |
|
|
93
|
+
| `${VAR:-default}` | `default` when `VAR` is unset or empty |
|
|
94
|
+
| `${VAR:=default}` | the same, and record `VAR=default` for later lookups |
|
|
95
|
+
| `\$` | a literal dollar sign |
|
|
96
|
+
|
|
97
|
+
**Single-quoted values are never expanded** โ that is the escape hatch for a literal `$`
|
|
98
|
+
inside a password. `$(command)` is kept as text and never run: a settings loader should do
|
|
99
|
+
no I/O beyond reading files. A default is plain text โ a quote, a brace or a `$` in one
|
|
100
|
+
(`${A:-${B}}`), or a `${` never closed, is a `FormatError` naming the line. A value spanning
|
|
101
|
+
lines must be quoted.
|
|
102
|
+
|
|
103
|
+
Expansion runs once every file of the cascade is read, so a value in `.env` may reference
|
|
104
|
+
one only `.env.local` defines; a real environment variable wins inside an expansion as it
|
|
105
|
+
does everywhere else. A self-referencing `A=${A:-x}` reads the value `A` has now (or the
|
|
106
|
+
default) โ it does not cycle. Anything still unresolved after five passes is a real cycle and
|
|
107
|
+
raises `VariableCircularReferenceError`.
|
|
108
|
+
|
|
109
|
+
## Typed settings from the cascade
|
|
110
|
+
|
|
111
|
+
`DotenvSettings` is a `BaseSettings` base whose subclass reads the layered cascade from a
|
|
112
|
+
sandboxed copy of `os.environ`:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from typing import ClassVar
|
|
116
|
+
|
|
117
|
+
from xtr_dotenv import DotenvSettings
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AppSettings(DotenvSettings):
|
|
121
|
+
_dotenv_path: ClassVar[str] = "/etc/app/.env"
|
|
122
|
+
_dotenv_env_key: ClassVar[str] = "APP_ENV"
|
|
123
|
+
_dotenv_default_env: ClassVar[str] = "prod"
|
|
124
|
+
|
|
125
|
+
database_url: str
|
|
126
|
+
log_level: str = "INFO"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Source priority is: init keyword arguments > real environment variables > the dotenv
|
|
130
|
+
cascade > secret files > field defaults. **No file the source reads writes into
|
|
131
|
+
`os.environ`.**
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
Everything the library raises derives from `DotenvError`, and carries the data as typed
|
|
136
|
+
attributes.
|
|
137
|
+
|
|
138
|
+
| Error | Raised when |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `FormatError` | A line cannot be parsed (bad binding, key without `=`); carries `.path`, `.line`, `.reason` |
|
|
141
|
+
| `PathError` | A file cannot be read; carries `.path` |
|
|
142
|
+
| `VariableCircularReferenceError` | Variables reference each other and never resolve; carries `.names` |
|
|
143
|
+
|
|
144
|
+
`FormatError` and `VariableCircularReferenceError` are also `ValueError`s; `PathError` is
|
|
145
|
+
also `OSError`. Existing `except` blocks keep working.
|
|
146
|
+
|
|
147
|
+
## Kernel / bundle
|
|
148
|
+
|
|
149
|
+
An application using [xtr-dependency-injection](../xtr-dependency-injection) lists
|
|
150
|
+
`DotenvBundle` in its `app/bundles.py`. **The bundle does not load `.env` files** โ
|
|
151
|
+
`Dotenv().boot_env(...)` runs before the kernel is built. What the bundle contributes is
|
|
152
|
+
the two commands that need the kernel to know its project directory:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
uv add "xtr-dotenv[di,console]"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
# app/bundles.py
|
|
160
|
+
from xtr_dotenv.bundle import DotenvBundle
|
|
161
|
+
|
|
162
|
+
BUNDLES = {DotenvBundle: {"all": True}}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# app/config/dotenv.py
|
|
167
|
+
from xtr_dependency_injection import configure
|
|
168
|
+
from xtr_dotenv.bundle import DotenvConfig
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@configure
|
|
172
|
+
def dotenv() -> DotenvConfig:
|
|
173
|
+
return DotenvConfig(path="%kernel.project_dir%/.env", env_key="APP_ENV")
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| `DotenvConfig` field | Meaning |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| `path` | The base `.env` path; default `%kernel.project_dir%/.env` โ parameter references are resolved by the kernel, and a relative path is taken from the project directory |
|
|
179
|
+
| `env_key` | The variable naming the active environment |
|
|
180
|
+
| `debug_key` | The variable naming debug mode |
|
|
181
|
+
| `test_envs` | Environments where the `.local` overlay is skipped |
|
|
182
|
+
| `prod_envs` | Environments considered production, used for debug defaulting |
|
|
183
|
+
|
|
184
|
+
### Commands
|
|
185
|
+
|
|
186
|
+
| Command | What it does |
|
|
187
|
+
| --- | --- |
|
|
188
|
+
| `dotenv:dump [env]` | Compile the cascade for `env` (default: the kernel's env) into `<path>.local.json`. Runs on a fresh environ with only the env key, so real secrets never land in the dump |
|
|
189
|
+
| `debug:dotenv [name]` | List the files that apply in cascade order (loaded / missing) and each variable's value per file, filtered by `name` when given |
|
|
190
|
+
|
|
191
|
+
The commands are only registered when the console bundle is active; on a headless
|
|
192
|
+
application the bundle is still valid and boots at zero config.
|
|
193
|
+
|
|
194
|
+
## Layout
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
xtr_dotenv/
|
|
198
|
+
โโโ dotenv.py the loader: parse, load, overload, populate, load_env, boot_env
|
|
199
|
+
โโโ dotenv_settings_source.py a pydantic-settings source that runs the cascade in a sandbox
|
|
200
|
+
โโโ dotenv_settings.py BaseSettings base declaring the cascade behind the real env
|
|
201
|
+
โโโ exception/ DotenvError + FormatError, PathError, VariableCircularReferenceError
|
|
202
|
+
โโโ command/ dotenv:dump and debug:dotenv (xtr-console commands)
|
|
203
|
+
โโโ bundle/ DotenvBundle for xtr-dependency-injection
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
209
|
+
`packages/xtr-dotenv`; run the commands below from there. The `python-xtr-dotenv`
|
|
210
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
uv sync --all-extras
|
|
214
|
+
uv run ruff check
|
|
215
|
+
uv run ruff format --check
|
|
216
|
+
uv run basedpyright
|
|
217
|
+
uv run ty check
|
|
218
|
+
uv run pytest
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT โ see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xtr-dotenv"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "Layered .env files loaded into the environment, and the same layers behind a typed settings model."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"dotenv",
|
|
11
|
+
"env",
|
|
12
|
+
"settings",
|
|
13
|
+
"configuration",
|
|
14
|
+
"layered",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"pydantic-settings>=2.4,<3",
|
|
27
|
+
"python-dotenv>=1.0,<2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Razvan Ceana"
|
|
32
|
+
email = "razvan@ceana.ro"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
di = ["xtr-dependency-injection>=1.0,<2"]
|
|
36
|
+
console = ["xtr-console>=1.0,<2"]
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"basedpyright>=1.21",
|
|
41
|
+
"ruff>=0.8",
|
|
42
|
+
"pytest>=8",
|
|
43
|
+
"pytest-cov>=5",
|
|
44
|
+
"anyio>=4.0",
|
|
45
|
+
"ty>=0.0.83",
|
|
46
|
+
"xtr-dependency-injection>=1.0,<2",
|
|
47
|
+
"xtr-console>=1.0,<2",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
52
|
+
build-backend = "uv_build"
|
|
53
|
+
|
|
54
|
+
[tool.basedpyright]
|
|
55
|
+
typeCheckingMode = "all"
|
|
56
|
+
pythonVersion = "3.11"
|
|
57
|
+
pythonPlatform = "All"
|
|
58
|
+
include = [
|
|
59
|
+
"src",
|
|
60
|
+
"tests",
|
|
61
|
+
]
|
|
62
|
+
exclude = [
|
|
63
|
+
"**/__pycache__",
|
|
64
|
+
"**/.venv",
|
|
65
|
+
"**/build",
|
|
66
|
+
"**/dist",
|
|
67
|
+
".tmp",
|
|
68
|
+
]
|
|
69
|
+
reportUnusedCallResult = "warning"
|
|
70
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
71
|
+
reportUnusedVariable = "error"
|
|
72
|
+
reportMissingParameterType = "error"
|
|
73
|
+
reportPrivateUsage = "error"
|
|
74
|
+
reportImplicitOverride = "none"
|
|
75
|
+
reportImplicitStringConcatenation = "none"
|
|
76
|
+
|
|
77
|
+
[tool.ruff]
|
|
78
|
+
target-version = "py311"
|
|
79
|
+
line-length = 100
|
|
80
|
+
src = [
|
|
81
|
+
"src",
|
|
82
|
+
"tests",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["ALL"]
|
|
87
|
+
ignore = [
|
|
88
|
+
"COM812",
|
|
89
|
+
"ISC001",
|
|
90
|
+
"D203",
|
|
91
|
+
"D213",
|
|
92
|
+
"CPY001",
|
|
93
|
+
"FBT001",
|
|
94
|
+
"FBT002",
|
|
95
|
+
"TD002",
|
|
96
|
+
"TD003",
|
|
97
|
+
"FIX002",
|
|
98
|
+
"TRY003",
|
|
99
|
+
"EM101",
|
|
100
|
+
"EM102",
|
|
101
|
+
]
|
|
102
|
+
fixable = ["ALL"]
|
|
103
|
+
unfixable = []
|
|
104
|
+
|
|
105
|
+
[tool.ruff.lint.per-file-ignores]
|
|
106
|
+
"tests/**/*.py" = [
|
|
107
|
+
"S101",
|
|
108
|
+
"S105",
|
|
109
|
+
"ARG",
|
|
110
|
+
"PLR2004",
|
|
111
|
+
"SLF001",
|
|
112
|
+
"D",
|
|
113
|
+
"ERA001",
|
|
114
|
+
"SIM112",
|
|
115
|
+
"TC002",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
[tool.ruff.lint.pydocstyle]
|
|
119
|
+
convention = "google"
|
|
120
|
+
|
|
121
|
+
[tool.ruff.format]
|
|
122
|
+
quote-style = "double"
|
|
123
|
+
indent-style = "space"
|
|
124
|
+
docstring-code-format = true
|
|
125
|
+
docstring-code-line-length = "dynamic"
|
|
126
|
+
|
|
127
|
+
[tool.ty.src]
|
|
128
|
+
include = [
|
|
129
|
+
"src",
|
|
130
|
+
"tests",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
[tool.pytest.ini_options]
|
|
134
|
+
minversion = "8.0"
|
|
135
|
+
testpaths = ["tests"]
|
|
136
|
+
addopts = [
|
|
137
|
+
"-ra",
|
|
138
|
+
"--strict-config",
|
|
139
|
+
"--strict-markers",
|
|
140
|
+
]
|
|
141
|
+
filterwarnings = ["error"]
|
|
142
|
+
|
|
143
|
+
[tool.coverage.run]
|
|
144
|
+
source = ["src"]
|
|
145
|
+
branch = true
|
|
146
|
+
|
|
147
|
+
[tool.coverage.report]
|
|
148
|
+
exclude_lines = [
|
|
149
|
+
"pragma: no cover",
|
|
150
|
+
"if TYPE_CHECKING:",
|
|
151
|
+
"raise NotImplementedError",
|
|
152
|
+
'^\s*\.\.\.$',
|
|
153
|
+
]
|