Coverage for src / openenv / envfiles / secret_env.py: 90.14%

53 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-25 13:36 +0000

1"""Helpers for sidecar .env secret reference files.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from pathlib import Path 

7 

8from openenv.core.errors import ValidationError 

9from openenv.core.models import SecretRef 

10 

11 

12BOT_SECRET_ENV_FILENAME = ".env" 

13_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") 

14 

15 

16def secret_env_path(directory: str | Path) -> Path: 

17 """Return the canonical sidecar env path for a manifest directory.""" 

18 return Path(directory) / BOT_SECRET_ENV_FILENAME 

19 

20 

21def load_secret_values(path: str | Path) -> dict[str, str]: 

22 """Load env key/value pairs from a sidecar .env file.""" 

23 env_path = Path(path) 

24 if not env_path.exists(): 

25 return {} 

26 return dict(parse_secret_env_text(env_path.read_text(encoding="utf-8"), label=str(env_path))) 

27 

28 

29def load_secret_refs(path: str | Path) -> list[SecretRef]: 

30 """Load secret refs from a sidecar .env file.""" 

31 return [ 

32 SecretRef(name=name, source=f"env:{name}", required=True) 

33 for name in load_secret_values(path) 

34 ] 

35 

36 

37def render_secret_env( 

38 secret_names: list[str], 

39 *, 

40 existing_values: dict[str, str] | None = None, 

41 display_name: str | None = None, 

42) -> str: 

43 """Render the canonical bot .env file.""" 

44 values = existing_values or {} 

45 names = _unique_preserving_order(secret_names) 

46 header = [ 

47 ( 

48 f"# Secret references for {display_name}" 

49 if display_name 

50 else "# Secret references" 

51 ), 

52 "# Keys declared here are synthesized into runtime.secret_refs for the bot.", 

53 ] 

54 lines = list(header) 

55 for name in names: 

56 lines.append("") 

57 lines.append(f"{name}={values.get(name, '')}") 

58 return "\n".join(lines).rstrip() + "\n" 

59 

60 

61def write_secret_env( 

62 path: str | Path, 

63 secret_names: list[str], 

64 *, 

65 existing_values: dict[str, str] | None = None, 

66 display_name: str | None = None, 

67) -> None: 

68 """Write the canonical bot .env file.""" 

69 Path(path).write_text( 

70 render_secret_env( 

71 secret_names, 

72 existing_values=existing_values, 

73 display_name=display_name, 

74 ), 

75 encoding="utf-8", 

76 ) 

77 

78 

79def parse_secret_env_text(text: str, *, label: str) -> list[tuple[str, str]]: 

80 """Parse a bot sidecar .env file.""" 

81 entries: list[tuple[str, str]] = [] 

82 seen: set[str] = set() 

83 for line_no, raw_line in enumerate(text.splitlines(), start=1): 

84 stripped = raw_line.strip() 

85 if not stripped or stripped.startswith("#"): 

86 continue 

87 if "=" not in raw_line: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 raise ValidationError(f"{label}:{line_no} must use KEY=VALUE syntax.") 

89 name, value = raw_line.split("=", 1) 

90 name = name.strip() 

91 if not _ENV_KEY_PATTERN.match(name): 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 raise ValidationError( 

93 f"{label}:{line_no} has an invalid env var name: {name!r}" 

94 ) 

95 if name in seen: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 raise ValidationError(f"{label}:{line_no} duplicates env var {name}.") 

97 seen.add(name) 

98 entries.append((name, value)) 

99 return entries 

100 

101 

102def _unique_preserving_order(items: list[str]) -> list[str]: 

103 """Remove duplicates from secret names while preserving the first declared order.""" 

104 seen: set[str] = set() 

105 result: list[str] = [] 

106 for item in items: 

107 if item not in seen: 107 ↛ 106line 107 didn't jump to line 106 because the condition on line 107 was always true

108 seen.add(item) 

109 result.append(item) 

110 return result