DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Empty vs Missing Env Before One Config Extract

Empty and missing environment variables are two different contracts. Record both behaviors before extracting any settings helper. The first safe change must keep every resolved value identical.

Inline getenv is not a stable API

Messy services still call os.environ.get in many modules. A shared helper looks cheaper than another copy. That extract often changes empty-string handling on day one.

Python treats a missing key and an empty string as different states. A get() default keeps an explicit empty string. An or-default chain silently replaces that empty string.

Boolean flags fail in a second distinct way. The call bool("false") evaluates to True in Python. A cleanup that adds bool() is not a rename.

Freeze three observations, not a folder layout

Capture missing keys, empty strings, and present values. Capture the current coercion rule as frozen data. Do not capture a preferred design from a blog post.

Write the matrix against the live functions. Do not write it against a planned helper. The live functions are the spec for one extract.

Secrets do not belong in this snapshot file. Use dummy keys such as APP_DEBUG only. Never dump production values into a fixture file.

Decision table for one flag and one integer

The table below is the original test artifact. Fill every cell from current production code. Do not fill those cells from memory.

Case Process env Call under test What to record
missing unset get("APP_DEBUG", "0") exact string
empty APP_DEBUG="" same call empty or default
false-string APP_DEBUG=false raw or bool() current rule
zero APP_DEBUG=0 raw, int, or bool current rule
true-string APP_DEBUG=true raw or parser current rule
integer APP_WORKERS=8 int(value) 8 or raise
spaces APP_WORKERS=" 8 " int(value) 8 or raise

Run the same table in CI and on a laptop. Mismatched shells are a real failure mode. systemd Environment files also strip quotes differently.

Dotenv loaders add a fourth source after process env. Do not merge that source in the first extract. Pin process env alone until those tests stay green.

Proposed characterization tests

The tests below are a proposed characterization harness. Treat them as unexecuted until a runner reports green. They pin current behavior, not a future design.

# proposed: tests/test_env_char.py
import pytest

from app import legacy_env as legacy

CASES = [
    ("missing", {}, "0"),
    ("empty", {"APP_DEBUG": ""}, ""),
    ("false", {"APP_DEBUG": "false"}, "false"),
    ("zero", {"APP_DEBUG": "0"}, "0"),
    ("true", {"APP_DEBUG": "true"}, "true"),
]


@pytest.mark.parametrize("name,env,expected", CASES)
def test_debug_raw_matches_legacy(name, env, expected, monkeypatch):
    monkeypatch.delenv("APP_DEBUG", raising=False)
    for key, value in env.items():
        monkeypatch.setenv(key, value)
    observed = legacy.read_debug_raw()
    assert observed == expected
Enter fullscreen mode Exit fullscreen mode

Add a second table for integer worker counts. Do not mix types inside a single assert. Coercion bugs hide inside those mixed-type asserts.

INT_CASES = [
    ("missing", {}, 4),
    ("eight", {"APP_WORKERS": "8"}, 8),
    ("spaces", {"APP_WORKERS": " 8 "}, 8),  # or expect ValueError
]


@pytest.mark.parametrize("name,env,expected", INT_CASES)
def test_workers_matches_legacy(name, env, expected, monkeypatch):
    monkeypatch.delenv("APP_WORKERS", raising=False)
    for key, value in env.items():
        monkeypatch.setenv(key, value)
    observed = legacy.read_workers()
    assert observed == expected
Enter fullscreen mode Exit fullscreen mode

If the live integer path raises ValueError, pin that type. Do not convert that raise into a silent default. The extract must fail in the same way.

def test_workers_blank_still_raises(monkeypatch):
    monkeypatch.setenv("APP_WORKERS", "")
    with pytest.raises(ValueError):
        legacy.read_workers()
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

Follow this sequence without skipping the commit. The commit is the brake for later diffs.

  1. Inventory every os.environ read inside one package.
  2. Group those reads by key, not by file name.
  3. Record missing, empty, and present results per key.
  4. Commit the characterization tests with no product change.
  5. Extract one function that satisfies those pinned tests.
  6. Stop after one key and leave remaining keys.

Command sequence for the inventory step comes next. Limit the first pass to Python files under app/.

rg -n "os.environ" -g "*.py" app | head -n 50
rg -n "getenv(" -g "*.py" app | head -n 50
Enter fullscreen mode Exit fullscreen mode

Command sequence for the test step stays equally small. Green tests are required before any helper file.

pytest -q tests/test_env_char.py
git add tests/test_env_char.py
git commit -m "Pin env missing/empty/coercion before config extract"
Enter fullscreen mode Exit fullscreen mode

The commit message itself is part of the contract. It states the freeze in one line. It does not claim a redesign of configuration.

Smallest safe change

Extract one reader, not a full Settings class. Keep the original module path stable for importers. Keep import-time side effects unchanged in this diff.

The next block is a proposed extract only. Leave it unexecuted until the matrix is green.

# proposed: app/env_read.py
import os
from typing import Optional


def read_raw(name: str, default: Optional[str] = None) -> Optional[str]:
    if name in os.environ:
        return os.environ[name]
    return default
Enter fullscreen mode Exit fullscreen mode

That function preserves empty strings on purpose here. It does not apply an or-default fallback. Wire exactly one call site after the extract.

# proposed: one call site after tests are green
from app.env_read import read_raw


def read_debug_raw() -> str:
    value = read_raw("APP_DEBUG", "0")
    return "0" if value is None else value
Enter fullscreen mode Exit fullscreen mode

Leave boolean parsing untouched in this first diff. A second diff can pin false-string and zero. Mixing both extracts hides which assert failed.

What this extract must not do

Do not load a .env file during the first extract. File load order is a second, separate contract. Pin that order in a later matrix only.

Do not cast values to bool in the helper yet. Do not strip whitespace in the same change. Do not log resolved values from the helper.

Logging can leak secrets into CI archives. Do not rename keys during the extract. Do not read a second process environment.

After the matrix is green

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. Use that option only after the matrix is green.

Draft the one-function extract against those pinned tests. Do not ask the model to invent the matrix.

Limitations

monkeypatch.setenv does not model systemd quoting rules well. It does not model Docker env_file merge order. It does not model Kubernetes empty versus unset keys.

Windows and POSIX treat some names differently in shells. Case folding is not only a documentation issue. Re-run the matrix on the real deployment OS.

Cached settings modules pin values at import time. Characterization must import after the env patch. Import-order bugs look like flaky characterization tests.

Shell export APP_DEBUG= and unset APP_DEBUG differ. Document which one the operator used in CI. Copy that exact form into the test names.

Who should skip this workflow

Skip this if the keys hold production secrets. A fixture file is the wrong store for them. Use throwaway names in every recorded case.

Skip this if no test runner exists in the repo. The extract then has no executable brake. Add pytest before any shared helper appears.

Skip this for a one-line script with a single getenv. The matrix then costs more than the risk. Keep the inline read in that tiny script.

Teams changing product defaults should not hide that change. Ship a default change as its own review. Do not bury it inside a cleanup diff.

Close

Empty, missing, and false-string values are measurable today. Measure them before the shared helper exists. One green matrix beats a larger settings rewrite.

Top comments (0)