DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Missing, Empty, and Invalid Env Before One Settings Extract

Scattered getenv calls look cheap to wrap later. They are not equivalent under a later extract. Empty strings, missing keys, and failed casts diverge. Hash that config surface before any helper lands. Keep the digest identical through one settings extract.

Core rule

Pin observed configuration behavior, not source layout. A model may propose the extract after the hash exists. The digest remains the only merge gate.

Why extracts drift

Three paths still share one environment key name. Missing, empty, and invalid values stay fully distinct. os.getenv with a default treats missing as that default. os.environ.get without a default returns None. int of an empty string raises ValueError. Reviewers rarely compare those three outcomes in diffs. Generated helpers often collapse them into one branch.

Import-time reads add a second trap for hashes. The module may call getenv during import. Tests then record the parent process environment instead. That digest is not portable across developer machines. Move reads into a called function first. Do not change values during that move.

What you pin

Record four fields for every environment access.

  1. Key name as a raw string.
  2. API used: getenv, get, or getitem.
  3. Value after local defaulting, if any.
  4. Exception type and message, if raised.

Also record the returned settings mapping. Sort every key. Dump JSON with separators (',', ':'). Hash that document with SHA-256. Exclude process id. Exclude wall-clock stamps. Exclude absolute paths unless an env key supplied them.

Artifact: hash_config_surface.py

The files below are a labeled proposal. They are not a live production harness. Do not load real secrets into the case table. The wrapper records os.getenv, os.environ.get, and os.environ.__getitem__.

# messy_settings.py — illustrative fixture, not a live service
import os


def load_raw():
    host = os.getenv("APP_HOST", "localhost")
    port_raw = os.environ.get("APP_PORT", "8080")
    port = int(port_raw)
    timeout_raw = os.environ.get("APP_TIMEOUT")
    if timeout_raw is None or timeout_raw == "":
        timeout_s = 30.0
    else:
        timeout_s = float(timeout_raw)
    debug = os.getenv("APP_DEBUG", "0") == "1"
    return {
        "host": host,
        "port": port,
        "timeout_s": timeout_s,
        "debug": debug,
    }
Enter fullscreen mode Exit fullscreen mode
# hash_config_surface.py — proposal only
from __future__ import annotations

import hashlib
import json
import os
import traceback
from typing import Any, Callable

from messy_settings import load_raw

CASES = [
    {"name": "all_missing", "env": {}},
    {"name": "valid", "env": {
        "APP_HOST": "api.internal",
        "APP_PORT": "9090",
        "APP_TIMEOUT": "2.5",
        "APP_DEBUG": "1",
    }},
    {"name": "empty_strings", "env": {
        "APP_HOST": "",
        "APP_PORT": "",
        "APP_TIMEOUT": "",
        "APP_DEBUG": "",
    }},
    {"name": "invalid_port", "env": {"APP_PORT": "abc"}},
    {"name": "invalid_timeout", "env": {"APP_TIMEOUT": "fast"}},
]


class EnvProbe(dict):
    def __init__(self, base: dict[str, str], log: list[dict[str, Any]]):
        super().__init__(base)
        self._log = log

    def get(self, key: str, default: Any = None) -> Any:
        present = key in self
        value = super().get(key, default)
        self._log.append({
            "api": "environ.get",
            "key": key,
            "present": present,
            "default_used": not present,
            "result": value,
        })
        return value

    def __getitem__(self, key: str) -> str:
        try:
            value = super().__getitem__(key)
        except KeyError as exc:
            self._log.append({
                "api": "environ.getitem",
                "key": key,
                "present": False,
                "error": type(exc).__name__,
            })
            raise
        self._log.append({
            "api": "environ.getitem",
            "key": key,
            "present": True,
            "result": value,
        })
        return value


def wrap_getenv(probe: EnvProbe, log: list[dict[str, Any]]) -> Callable[..., Any]:
    def getenv(key: str, default: Any = None) -> Any:
        present = key in probe
        value = probe.get(key, default) if present else default
        # probe.get already logged environ.get; mark getenv explicitly
        log.append({
            "api": "getenv",
            "key": key,
            "present": present,
            "default_used": not present,
            "result": value,
        })
        return value
    return getenv


def run_case(env: dict[str, str]) -> dict[str, Any]:
    log: list[dict[str, Any]] = []
    probe = EnvProbe(env, log)
    old_environ = os.environ
    old_getenv = os.getenv
    os.environ = probe  # type: ignore[assignment]
    os.getenv = wrap_getenv(probe, log)  # type: ignore[assignment]
    try:
        result = load_raw()
        return {"ok": True, "result": result, "accesses": log, "error": None}
    except Exception as exc:
        return {
            "ok": False,
            "result": None,
            "accesses": log,
            "error": {
                "type": type(exc).__name__,
                "msg": str(exc),
                "exc_class": traceback.format_exc().splitlines()[-1],
            },
        }
    finally:
        os.environ = old_environ
        os.getenv = old_getenv


def canonical(doc: Any) -> str:
    return json.dumps(doc, sort_keys=True, separators=(",", ":"), default=str)


def main() -> None:
    rows = []
    for case in CASES:
        rows.append({"name": case["name"], **run_case(case["env"])})
    text = canonical(rows)
    digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
    print(text)
    print(digest)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Empty is not missing in this table. Invalid is not empty in this table. Those two rows catch most extract regressions.

Numbered workflow

1. Isolate one reader

Pick one module that reads process environment keys. Do not start with the whole package. Keep network imports out of that file. If import still talks to disk, stop and split later.

2. Inventory keys from the file

Grep first. Do not trust memory for key names.

rg -n "os\\.environ|os\\.getenv|getenv\(" messy_settings.py
Enter fullscreen mode Exit fullscreen mode

Write each key into the case table. Add no extra keys yet. Extra keys change the access log and the digest.

3. Build four rows per risky key

Cover four rows for every parsed key.

  1. Key missing from the mapping.
  2. Key present with a valid value.
  3. Key present and equal to empty.
  4. Key present and illegal for the cast.

Boolean flags need an extra fifth row. Cover both "0" and "1". Do not treat any non-empty string as true unless the module already does.

4. Freeze the digest in git

Run the hasher on a clean tree. Store the digest beside the fixture.

python hash_config_surface.py > /tmp/surface.json
tail -n 1 /tmp/surface.json > config_surface.sha256
git add hash_config_surface.py messy_settings.py config_surface.sha256
Enter fullscreen mode Exit fullscreen mode

Treat a digest change as a failed pin. Do not treat it as a style comment.

5. Propose the extract only after the pin

Keep secrets out of any prompt you send. Paste the reader function and the case names only. Ask for one function named load_settings. Ask it to preserve missing, empty, and invalid paths. Ask it not to add validation.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. Use that pair to draft the helper on a throwaway branch. Do not let the model rewrite the hasher. Local digest comparison stays the merge gate.

6. Apply one extract

Move the reads into load_settings(). Keep default strings identical. Keep int and float casts identical. Keep exception types identical. Add no new required keys. Add no extra logging. One function is the whole change.

# proposed shape — still gated by the digest
def load_settings():
    return load_raw()
Enter fullscreen mode Exit fullscreen mode

Replace call sites one module at a time. Do not rename keys in the same patch. Do not switch getenv to environ.get in the same patch.

7. Re-hash and compare

Run the same command on the branch. Compare the last line only.

python hash_config_surface.py | tail -n 1 | diff -u config_surface.sha256 -
Enter fullscreen mode Exit fullscreen mode

Any mismatch means behavior moved. Revert the extract. Shrink the patch. Repeat the hash. Do not “fix” the fixture to match new defaults.

8. Gate the branch

Add a one-line check in CI. Keep it boring.

test "$(python hash_config_surface.py | tail -n 1)" = "$(cat config_surface.sha256)"
Enter fullscreen mode Exit fullscreen mode

Fail the job on mismatch. Do not print env values in CI logs. The digest is enough.

Decision table

Observation Action
Digest matches and call sites compile Merge the extract
Digest differs and a new key appears Drop the extra getenv
Digest differs on empty versus missing Restore the old branch
Exception type changed Restore the original raise
"" now equals the numeric default Reject the patch
Model added validation or logging Reject the patch
Import order changed, digest stable Inspect, then accept

Use the table during review. Do not argue from taste. The row names already encode the failure.

Commands for a mismatch

Split the JSON when the digest moves. Do not guess.

python hash_config_surface.py | head -n -1 > /tmp/new.json
python - <<'PY'
import json, pathlib
old = json.loads(pathlib.Path("/tmp/old.json").read_text())
new = json.loads(pathlib.Path("/tmp/new.json").read_text())
for a, b in zip(old, new):
    if a != b:
        print(a["name"])
        print("old_error", a.get("error"))
        print("new_error", b.get("error"))
        print("old_result", a.get("result"))
        print("new_result", b.get("result"))
PY
Enter fullscreen mode Exit fullscreen mode

Fix the extract, not the case names. Recreate /tmp/old.json from main if needed.

Limitations

This pin does not wrap os.environ.setdefault. It does not wrap os.environ.copy. It does not see os.environ.update. It does not load .env files unless the module imports that loader. It does not prove thread safety around mutation. It does not attest production secrets. The access log can double-count when getenv calls environ.get. Keep the wrapper consistent across both sides of the diff. Do not mix wrapper versions in one comparison.

JSON key order is frozen by sort_keys. Float formatting is not frozen beyond Python str. If timeout math changes representation, the digest moves. That is intended. Do not round values inside the hasher.

Who should skip this

Skip this if the app already uses a typed settings library with tests. Skip this if the case table would contain real credentials. Skip this if config is built only at deploy time from generated files. Skip this if several processes mutate the same environment map. Skip this if you need property-based parsing proofs. This workflow records a finite table. It is not a fuzzer.

Close

Extract settings after the surface hash exists. Keep missing, empty, and invalid on separate rows. Let a model draft the helper if you want. Merge only when the digest is unchanged. If you reuse the hasher, publish the digest command and the mismatch, not a model score.

Top comments (0)