${ENV_VAR} interpolation in config loading (2/13) - #205
Open
alex-clickhouse wants to merge 1 commit into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
Adds ${ENV_VAR} / ${ENV_VAR:-default} interpolation to config loading (post-merge across layers), plus scalar re-coercion so interpolated values behave like literal YAML types (e.g., enabled: ${FLAG} with FLAG=false disables).
Changes:
- Introduces recursive env-var interpolation with required/optional semantics and
$$escaping, applied after deep-merge during config load. - Adds a scalar coercion layer (
@coerced) so declaredbool/int/float(incl.Optionalandlist[...]) fields are normalized after interpolation. - Improves CLI UX by rendering config-load failures (e.g., missing required env vars) as clean Click errors; adds extensive tests and documentation.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_config_env.py |
New end-to-end and unit tests covering env interpolation + scalar coercion behavior. |
nerve/config.py |
Implements env interpolation, wires it into load_config/load_mcp_servers, and applies @coerced across config from_dict builders. |
nerve/coerce.py |
New module providing lenient scalar coercion and the @coerced decorator for config builders. |
nerve/cli.py |
Converts ConfigError during config load into a clean ClickException (no traceback). |
docs/config.md |
Documents env references, escaping, and post-interpolation scalar coercion semantics. |
config.example.yaml |
Adds comments documenting env-var reference support and escaping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 28, 2026 12:11
a65720e to
e6288f5
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 28, 2026 12:36
e6288f5 to
1eeb089
Compare
alex-clickhouse
marked this pull request as ready for review
July 28, 2026 14:06
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 28, 2026 14:22
1eeb089 to
880b7fd
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
2 times, most recently
from
July 29, 2026 10:31
6dd6caa to
ee7e26a
Compare
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 30, 2026 08:01
ee7e26a to
9bee224
Compare
Any string value may reference ${VAR} or ${VAR:-default}. Only the braced form
is interpolated, so bcrypt hashes, JWT secrets and connection strings containing
a bare $ are never touched; $$ escapes a literal $. Interpolation runs once after
the local overlay is merged, so the secrets layer can reference the environment
too. An unresolved required reference fails the load and names every missing
variable.
Interpolation returns strings, which would otherwise destroy every declared
scalar type: `enabled: ${FLAG}` with FLAG=false becomes the string "false", and
bool("false") is true. A coercion decorator converts values back to their
declared types, covering bool, int, float, Optional and list. An eager
bool(d.get(...)) inside a from_dict defeats it, so those were removed.
Co-Authored-By: Claude <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
config-refactor/02-env-vars
branch
from
July 30, 2026 10:06
9bee224 to
f9cf1ca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
${VAR}(required — loading fails, listing every unresolved name) and${VAR:-default}(optional, shell:-semantics) in any string value in any config layer. Interpolation runs once, after all layers are merged. Only the braced form is touched, so bcrypt hashes ($2b$...) and connection strings are safe as-is.Because interpolation is string substitution, an interpolated value arrives as
str. Fields declaredint,floatorbool— includingX | Noneandlist[X]— are converted back to their declared type when the config object is built. Booleans are parsed, not tested for truthiness, and an unparseable value keeps the field's documented default with a log line namingClass.field.Why
Secrets should be suppliable from the environment or a secret store rather than written into a file, especially once a config layer is git-tracked and meant to be committed.
The type conversion is what makes that usable rather than a trap. Without it,
port: ${PORT}arrives as"8900"and raises at its first arithmetic orsocket.bind. Worse,enabled: ${FLAG}withFLAG=falseis a non-empty string and therefore on — a flag that reads as disabled and behaves as enabled. A quoted YAML scalar (enabled: "false") reaches the same code path with no environment variable involved at all.Lockdown makes this load-bearing rather than a convenience: it discards the machine-local layers, so every per-machine value has to be an environment reference in the tracked settings. That is exactly the shape that breaks without conversion.
Parsing rather than guessing matters in both directions. An empty variable (
FLAG=) and a bareenabled:are both off, so blanking a variable reliably disables a feature instead of falling back to whatever the tracked config said; an unrecognised value falls back to the declared default rather than flipping the flag to the opposite of both what it says and what the config declares.🤖 Generated with Claude Code