Skip to content
GitHub
Logic Errors

Import-time env capture forces lazy-import hacks; read env at call time

Import-Time Env Capture Forces Lazy-Import Hacks

Type: Principle / antipattern (no severity — applies whenever a module’s behavior is parameterized by env that callers set at runtime) · Applies to: any module read by more than one consumer that each pins its own configuration before driving it.

Problem

A module captures its configuration from os.environ at import time into module-level constants:

_WORLD_NAME = os.environ.get("SPECTRAL_TAX_PREP_WORLD_NAME", "tax-prep-1040-ty2025")
_CUSTOMER_DOMAIN_SLUG = os.environ.get("SPECTRAL_TAX_PREP_DOMAIN_SLUG", "...")

But the consumers each pin their own identity right before driving the module (the validation gate uses one domain slug; the CLI uses another). With import-time capture, a plain cached import freezes whatever env existed when the module was first imported anywhere in the process — usually the defaults, or another consumer’s values. The configuration the caller just set never takes effect.

The usual “fix” papers over the symptom instead of removing the cause: a bespoke loader (importlib.spec_from_file_location + module_from_spec + exec_module) that re-executes the module body on every call so the just-set env is re-read. In this codebase that loader (_import_phase_c_author) also walked the directory tree to locate a tools/dev/*.py script by path — a packaged app (spectral_test_agents) reaching out to a loose dev script through filesystem discovery, which is its own smell.

Investigation

What happened (SPEC-690, af592a5a)

The tax-prep authoring harness lived at tools/dev/phase_c_author.py and captured ~10 env values at import. Two in-package consumers (the system: validation driver and the tax-prep CLI) each did:

os.environ.update(_MY_DEDICATED_IDENTITY) # pin my own world/customer slug
phase_c_author = _import_phase_c_author() # re-exec the module so the env "takes"

The re-exec loader existed only because of the import-time capture. It worked, but it was fragile (any earlier import would have frozen the wrong values) and it forced the package to depend on a tools/dev script via a path-walk.

The trap

A normal from … import author would have broken identity: the module body runs once, cached, with whatever env was present at first import. The behavior silently depended on import order — undetectable until a second consumer ran in the same process with different values.

Root Cause

Import-time os.environ capture into module-level constants. It binds configuration to import timing rather than use, which is almost never what you want for a module that more than one caller parameterizes. Every workaround (lazy import, re-exec loader, “import it last”) is just managing the timing the capture created.

Solution

Read env at call time through small accessor functions. Nothing is captured at import, so a plain cached import is import-order-independent and the loader hack deletes cleanly.

# Before — frozen at import; forces a re-exec loader to apply caller env
_API_BASE = os.environ.get("SPECTRAL_API_BASE_URL", "http://127.0.0.1:8000")
_WORLD_NAME = os.environ.get("SPECTRAL_TAX_PREP_WORLD_NAME", "tax-prep-1040-ty2025")
# After — resolved per call; a plain `from … import author` is order-independent
def _api_base() -> str:
return os.environ.get("SPECTRAL_API_BASE_URL", "http://127.0.0.1:8000")
def _world_name() -> str:
return os.environ.get("SPECTRAL_TAX_PREP_WORLD_NAME", "tax-prep-1040-ty2025")

Consumer, now with a normal import:

from spectral_test_agents.tax_prep.authoring import author # cached, order-independent
os.environ.update(_VALIDATION_IDENTITY) # pin my identity
await author.ensure_deployed_world(...) # accessors read it at call time

Implementation notes

  • Genuinely-constant, non-env values (timeouts, computed paths) stay module constants — only env-derived values move to accessors.
  • The relocation that this enabled also put the harness in the package (tax_prep/authoring/), with its shared dependency (customer_seed, used by qa too) legitimately remaining in tools/dev and reached via the repo’s PEP420 idiom from tools.dev import customer_seed (pytest needs pythonpath=["../.."] to resolve it under the test-agents rootdir).

Prevention

Best practices

  • When a module’s behavior is parameterized by env that callers set at runtime, read env at the point of use, never into module-level constants.
  • Treat a re-exec / path-walk importer as a symptom: ask “what made a normal import insufficient?” — it is almost always import-time capture upstream.
  • A packaged module that must locate another module by filesystem path is a seam smell; prefer a real import (relocate code, or reach shared dev utilities via the established namespace idiom).

Warning signs

  • _CONST = os.environ.get(...) at module top level in a module with >1 caller.
  • A comment like “set the env before this import” or “import this lazily so …”.
  • importlib.util.spec_from_file_location used to load first-party code.

References

  • Commit af592a5a (SPEC-690) — converted author.py to call-time accessors and deleted the _import_phase_c_author re-exec loader.
  • Memory: project_test_agents_authoring_harness_location_and_import_contract.