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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""Helpers for sidecar .env secret reference files."""
3from __future__ import annotations
5import re
6from pathlib import Path
8from openenv.core.errors import ValidationError
9from openenv.core.models import SecretRef
12BOT_SECRET_ENV_FILENAME = ".env"
13_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
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
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)))
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 ]
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"
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 )
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
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