Coverage for src/footman/env_files.py: 97%
24 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Load a .env file into the run's environment — mounted, never ambient.
3`plugin("footman.env_files")` in a tasks file switches it on; unmounted, it is
4inert metadata like any other plugin. At the invocation's single-threaded
5moment the file is read and every key the environment does not already carry
6is set — **env wins**, so a real environment variable always beats the file,
7and a run is never surprised by a checkout. Parsing is python-dotenv's (an
8optional dependency, taught by name when it is missing), with interpolation
9off: a value is the text on its line.
11The default file is `.env` in the invocation's directory; `--env-file=PATH`
12names another (path-typed, so completion offers files), and a bare
13`--env-file` asks for the default out loud. A missing default is nothing to
14do — unless someone asked: the bare mention refuses where plain absence
15shrugs, and a missing *named* file always refuses. The manifest refresh
16child runs this hook too, with no command line, so what completion bakes
17reflects the default file — availability is re-checked live at execution
18either way.
19"""
21from __future__ import annotations
23import os
24from pathlib import Path
25from typing import Annotated
27import footman
28from footman import GlobalOption, matching
30ENV_FILE = GlobalOption(
31 "env-file",
32 # `.env`, `.env.local`, `.env.production` — the family, rather than every
33 # file in the directory. Completion only: a path typed anyway still binds.
34 Annotated[Path, matching(".env*")],
35 default=Path(".env"),
36 help="the .env file to load",
37)
40@footman.pre_tasks
41def load(inv: footman.Invocation) -> None:
42 # pre_tasks runs before global binding, so this reads the lexical CLI —
43 # and presence is the question there, never value truthiness: a bare
44 # mention arrives as the empty string, and Path("") reads as ".".
45 given = "env_file" in inv.cli
46 raw = inv.cli.get("env_file")
47 named = Path(raw) if isinstance(raw, str) and raw else None
48 path = named if named is not None else Path(inv.cwd or ".") / ".env"
49 if not path.is_file():
50 if given:
51 # Named a file, or asked for the default out loud — both are
52 # someone asking, so a missing file refuses rather than shrugs.
53 footman.fail(f"--env-file: {path} does not exist")
54 return # no .env is nothing to do, not a problem
55 try:
56 from dotenv import dotenv_values
57 except ImportError:
58 footman.fail(
59 "plugin('footman.env_files') needs the python-dotenv package — "
60 "add it to the project's environment (uv add python-dotenv), "
61 "or drop the mount"
62 )
63 for key, value in dotenv_values(path, interpolate=False).items():
64 if value is not None: 64 ↛ 63line 64 didn't jump to line 63 because the condition on line 64 was always true
65 os.environ.setdefault(key, value) # env wins, always