Skip to content

${ENV_VAR} interpolation in config loading (2/13) - #205

Open
alex-clickhouse wants to merge 1 commit into
config-refactor/01-pathsfrom
config-refactor/02-env-vars
Open

${ENV_VAR} interpolation in config loading (2/13)#205
alex-clickhouse wants to merge 1 commit into
config-refactor/01-pathsfrom
config-refactor/02-env-vars

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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 declared int, float or bool — including X | None and list[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 naming Class.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 or socket.bind. Worse, enabled: ${FLAG} with FLAG=false is 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 bare enabled: 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

@alex-clickhouse alex-clickhouse changed the title ${ENV_VAR} interpolation in config loading ${ENV_VAR} interpolation in config loading (2/13) Jul 28, 2026
@alex-clickhouse
alex-clickhouse requested a review from Copilot July 28, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 declared bool/int/float (incl. Optional and list[...]) 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.

Comment thread nerve/config.py Outdated
Comment thread nerve/coerce.py
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/02-env-vars branch from a65720e to e6288f5 Compare July 28, 2026 12:11
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/02-env-vars branch from e6288f5 to 1eeb089 Compare July 28, 2026 12:36
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review July 28, 2026 14:06
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/02-env-vars branch from 1eeb089 to 880b7fd Compare July 28, 2026 14:22
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/02-env-vars branch 2 times, most recently from 6dd6caa to ee7e26a Compare July 29, 2026 10:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/02-env-vars branch from ee7e26a to 9bee224 Compare July 30, 2026 08:01
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
alex-clickhouse force-pushed the config-refactor/02-env-vars branch from 9bee224 to f9cf1ca Compare July 30, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants