Skip to content

[BUG] load_dotenv(override=True) at import time lets a .env near the package override the shell environment and the working-directory .env #128

Description

@joshfactorial

Summary

src/llmflux/core/client.py:14 calls load_dotenv(override=True) at import time. Because llmflux/__init__.py imports client transitively, merely importing llmflux stamps a .env file over the process environment — and override=True means it beats real environment variables.

Worse, python-dotenv resolves that file relative to client.py's own directory, not the user's working directory. For an editable install, that means a .env anywhere at or above the checkout wins over the .env in the directory the user is running from.

The result is a configuration that cannot be changed by editing the .env you are looking at, with no diagnostic explaining why.

Two loaders, opposite precedence

Loader Search origin Precedence vs os.environ
Config._load_env_file (config.py:240-287) cwd + 2 parents Respects it — sets a key only if key not in os.environ
client.py:14 load_dotenv(override=True) walks up from client.py Overrides it

The second runs first (at import), so it wins. .env.example documents the opposite: "Path precedence: code paths > environment variables > defaults".

Reproduction

Two .env files, one in the checkout root and one in the run directory:

checkout/.env : LLMFLUX_WORKSPACE=/scratch/STALE-repo-root/llmflux
rundir/.env   : LLMFLUX_WORKSPACE=/scratch/CORRECT-from-cwd/llmflux

Running a script from rundir:

before import : None
after  import : /scratch/STALE-repo-root/llmflux     ← set by importing llmflux
Config sees   : /scratch/STALE-repo-root/llmflux

An exported variable loses the same way:

$ SLURM_ACCOUNT=from-export python -c "
import os; import llmflux.core.config
print(os.environ['SLURM_ACCOUNT'])"
test        # ← the .env value, not from-export

Note the python -c detail: python-dotenv treats -c, REPLs and notebooks as interactive and searches cwd there, but walks up from client.py for a real script or console entry point. So the same .env can win or lose depending on how Python was started, which makes this very hard to reason about from the outside.

Real-world cost

Encountered on Delta with an editable install at /projects/<...>/code/llmflux. A stale LLMFLUX_WORKSPACE in /projects/<...>/code/.env — one level above the checkout — silently overrode both the shell environment and the .env in the working directory. Editing the working-directory .env had no effect; env | grep LLMFLUX showed nothing, because the value is injected inside the process. It took three rounds of debugging and a walk of every .env above the package directory to locate the responsible file.

Suggested fix

Config already has a .env loader with correct, documented precedence. Options, roughly in order of preference:

  1. Delete the load_dotenv call. client.py reads its own settings via os.getenv and any entry point that builds a Config gets .env loading anyway. This removes an import-time side effect on global state, which is the underlying problem.
  2. Change to load_dotenv(override=False, dotenv_path=find_dotenv(usecwd=True)) so both loaders agree on origin and precedence.
  3. Keep it, but move it out of import time into an explicit entry-point call.

Whichever route, .env resolution should happen in exactly one place with one documented rule.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions