xtr-dotenv
Layered .env files loaded into the environment, and the same layers behind a typed settings model.
Why?
Real applications keep several .env files layered so a developer laptop, a CI runner and a
production host each end up with the settings that suit, without any one file knowing about
the other. This library is the loader every layer runs through, plus a pydantic-settings
source that feeds a typed model from the same cascade — without ever touching
os.environ behind your back.
- 📚 The layered cascade —
.env→.env.local→.env.{env}→.env.{env}.local, with a.env.distread instead of a missing.env, and real environment variables always winning. - 🧪 Testable end-to-end — hand the loader a plain
dictand no other test in the same process can see anything it did. - 🧩 Pydantic-settings integration —
DotenvSettingsreads the same cascade from a sandboxed copy ofos.environ, so a model gets the values without side effects. - 🌐 Variable expansion —
$VAR,${VAR},${VAR:-default},${VAR:=default},\$as a literal. Single-quoted values are literal too.$(command)is kept as text, never run. - ⚡ A dumped fast path —
dotenv:dumpwrites the compiled cascade to.env.local.json;boot_envreads that one file instead of the cascade.
Install
uv add xtr-dotenv # the loader and the settings source
uv add "xtr-dotenv[di]" # + a DotenvBundle for xtr-dependency-injection
uv add "xtr-dotenv[console]" # + the dotenv:dump and debug:dotenv commands
Requires Python 3.11+.
Quick start
Call Dotenv().boot_env(...) at the very top of your entry point — before anything
reads a settings model, and before you build a kernel:
from pathlib import Path
from xtr_dotenv import Dotenv
Dotenv().boot_env(str(Path(__file__).parent / ".env"))
# ... only now import the kernel, settings, etc.
from app.kernel import kernel
raise SystemExit(kernel.run(...))
The loader is stateless: hand it a MutableMapping[str, str] as environ= and no other
code sees a thing.
sandbox: dict[str, str] = {}
Dotenv(environ=sandbox).load_env("/etc/app/.env")
The file cascade
load_env(path) reads, in order:
| Order | File | When it applies |
|---|---|---|
| 1 | path |
Always, or path.dist when path is missing |
| 2 | (reads env key from environ; defaults to default_env) |
|
| 3 | path.local |
Only outside test_envs — a developer's per-machine overrides |
| 4 | (re-reads env key) | The .local file may have set a different environment |
| 5 | path.{env} |
When env is not "local" |
| 6 | path.{env}.local |
Same — a per-machine override for that environment |
Later files override earlier ones. A real environment variable always wins over any file
unless override_existing_vars=True is passed. XTR_DOTENV_VARS tracks which names came
from a file so a later file may replace them; XTR_DOTENV_PATH records the base path so
tooling can find it.
Variable expansion
| Written | Means |
|---|---|
$VAR, ${VAR} |
the value of VAR, or empty when unset |
${VAR:-default} |
default when VAR is unset or empty |
${VAR:=default} |
the same, and record VAR=default for later lookups |
\$ |
a literal dollar sign |
Single-quoted values are never expanded — that is the escape hatch for a literal $
inside a password. $(command) is kept as text and never run: a settings loader should do
no I/O beyond reading files. A default is plain text — a quote, a brace or a $ in one
(${A:-${B}}), or a ${ never closed, is a FormatError naming the line. A value spanning
lines must be quoted.
Expansion runs once every file of the cascade is read, so a value in .env may reference
one only .env.local defines; a real environment variable wins inside an expansion as it
does everywhere else. A self-referencing A=${A:-x} reads the value A has now (or the
default) — it does not cycle. Anything still unresolved after five passes is a real cycle and
raises VariableCircularReferenceError.
Typed settings from the cascade
DotenvSettings is a BaseSettings base whose subclass reads the layered cascade from a
sandboxed copy of os.environ:
from typing import ClassVar
from xtr_dotenv import DotenvSettings
class AppSettings(DotenvSettings):
_dotenv_path: ClassVar[str] = "/etc/app/.env"
_dotenv_env_key: ClassVar[str] = "APP_ENV"
_dotenv_default_env: ClassVar[str] = "prod"
database_url: str
log_level: str = "INFO"
Source priority is: init keyword arguments > real environment variables > the dotenv
cascade > secret files > field defaults. No file the source reads writes into
os.environ.
Errors
Everything the library raises derives from DotenvError, and carries the data as typed
attributes.
| Error | Raised when |
|---|---|
FormatError |
A line cannot be parsed (bad binding, key without =); carries .path, .line, .reason |
PathError |
A file cannot be read; carries .path |
VariableCircularReferenceError |
Variables reference each other and never resolve; carries .names |
FormatError and VariableCircularReferenceError are also ValueErrors; PathError is
also OSError. Existing except blocks keep working.
Kernel / bundle
An application using xtr-dependency-injection lists
DotenvBundle in its app/bundles.py. The bundle does not load .env files —
Dotenv().boot_env(...) runs before the kernel is built. What the bundle contributes is
the two commands that need the kernel to know its project directory:
uv add "xtr-dotenv[di,console]"
# app/bundles.py
from xtr_dotenv.bundle import DotenvBundle
BUNDLES = {DotenvBundle: {"all": True}}
# app/config/dotenv.py
from xtr_dependency_injection import configure
from xtr_dotenv.bundle import DotenvConfig
@configure
def dotenv() -> DotenvConfig:
return DotenvConfig(path="%kernel.project_dir%/.env", env_key="APP_ENV")
DotenvConfig field |
Meaning |
|---|---|
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 |
env_key |
The variable naming the active environment |
debug_key |
The variable naming debug mode |
test_envs |
Environments where the .local overlay is skipped |
prod_envs |
Environments considered production, used for debug defaulting |
Commands
| Command | What it does |
|---|---|
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 |
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 |
The commands are only registered when the console bundle is active; on a headless application the bundle is still valid and boots at zero config.
Layout
xtr_dotenv/
├── dotenv.py the loader: parse, load, overload, populate, load_env, boot_env
├── dotenv_settings_source.py a pydantic-settings source that runs the cascade in a sandbox
├── dotenv_settings.py BaseSettings base declaring the cascade behind the real env
├── exception/ DotenvError + FormatError, PathError, VariableCircularReferenceError
├── command/ dotenv:dump and debug:dotenv (xtr-console commands)
└── bundle/ DotenvBundle for xtr-dependency-injection
Development
Developed in the python-xtr monorepo, under
packages/xtr-dotenv; run the commands below from there. The python-xtr-dotenv
repository is a read-only copy, so send issues and pull requests to the monorepo.
uv sync --all-extras
uv run ruff check
uv run ruff format --check
uv run basedpyright
uv run ty check
uv run pytest
License
MIT — see LICENSE.
Release files for xtr-dotenv 1.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xtr_dotenv-1.3.0.tar.gz | 18.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_dotenv-1.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 42.6 kB
Release files / xtr_dotenv-1.3.0.tar.gz
| Download URL | xtr_dotenv-1.3.0.tar.gz |
|---|---|
| Size | 18.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8cf0cfb6ede90678d4dfce6edc381275b3f5804a29e9b99438949b7c28453e7f
|
|
BLAKE2b-256 checksum How to use checksums |
36511e0b07e2d2102a31e5fc57d9579c36931f630c52cb221bddfb3a4b9ba81b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency logRelease files / xtr_dotenv-1.3.0-py3-none-any.whl
| Download URL | xtr_dotenv-1.3.0-py3-none-any.whl |
|---|---|
| Size | 24.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c22b6f921d67a72c0a67f88fdaf1373992252b692495719942a39ed62949a8ca
|
|
BLAKE2b-256 checksum How to use checksums |
1305ddbcfcd39ded67b0a841810dd30a463fd3e44c94d5618491a297ac75b199
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency log