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

1"""Load a .env file into the run's environment — mounted, never ambient. 

2 

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. 

10 

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""" 

20 

21from __future__ import annotations 

22 

23import os 

24from pathlib import Path 

25from typing import Annotated 

26 

27import footman 

28from footman import GlobalOption, matching 

29 

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) 

38 

39 

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