198 lines
6.0 KiB
Python
198 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Sync .env.example: keep structure/comments, merge in keys from .env."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKIP_DIRS = {".git", "site", "__pycache__"}
|
|
|
|
|
|
class EnvParseError(Exception):
|
|
pass
|
|
|
|
|
|
def discover_pairs(repo_root: Path) -> list[tuple[Path, Path]]:
|
|
pairs: list[tuple[Path, Path]] = []
|
|
for example_path in sorted(repo_root.rglob(".env.example")):
|
|
rel_parts = set(example_path.relative_to(repo_root).parts)
|
|
if rel_parts & SKIP_DIRS:
|
|
continue
|
|
env_path = example_path.with_name(".env")
|
|
pairs.append((example_path, env_path))
|
|
return pairs
|
|
|
|
|
|
def parse_env_key_values(path: Path) -> tuple[list[str], dict[str, str]]:
|
|
keys: list[str] = []
|
|
values: dict[str, str] = {}
|
|
seen: set[str] = set()
|
|
|
|
for idx, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
|
|
if "=" not in line:
|
|
raise EnvParseError(f"{path}: line {idx} is not KEY=VALUE format")
|
|
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
if not key:
|
|
raise EnvParseError(f"{path}: line {idx} has empty key")
|
|
|
|
if key in seen:
|
|
raise EnvParseError(f"{path}: duplicate key '{key}'")
|
|
seen.add(key)
|
|
keys.append(key)
|
|
values[key] = value
|
|
|
|
return keys, values
|
|
|
|
|
|
def parse_env_keys(path: Path) -> list[str]:
|
|
keys, _ = parse_env_key_values(path)
|
|
return keys
|
|
|
|
|
|
def merge_env_into_example(env_path: Path, example_values: dict[str, str]) -> str:
|
|
"""
|
|
Build .env.example from .env structure.
|
|
.env defines: keys, order, comments.
|
|
.env.example provides: default values (or placeholders for new keys).
|
|
"""
|
|
out_lines: list[str] = []
|
|
|
|
# Read .env to get structure/order/comments, fill in values from .env.example
|
|
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in raw_line:
|
|
out_lines.append(raw_line)
|
|
continue
|
|
|
|
key, value_from_env = raw_line.split("=", 1)
|
|
key = key.strip()
|
|
|
|
# Use default from .env.example if it exists, otherwise use placeholder
|
|
if key in example_values:
|
|
out_lines.append(f"{key}={example_values[key]}")
|
|
else:
|
|
# New key not in template: use placeholder
|
|
out_lines.append(f"{key}=__CHANGEME__")
|
|
|
|
return "\n".join(out_lines) + "\n"
|
|
|
|
|
|
def compare_env(example_path: Path, env_path: Path) -> list[str]:
|
|
"""Check if .env.example matches .env schema."""
|
|
errors: list[str] = []
|
|
|
|
if not env_path.exists():
|
|
rel_env = env_path.relative_to(REPO_ROOT)
|
|
errors.append(f"Missing .env (schema): {rel_env}")
|
|
return errors
|
|
|
|
try:
|
|
env_keys, _ = parse_env_key_values(env_path)
|
|
example_keys = parse_env_keys(example_path) if example_path.exists() else []
|
|
except EnvParseError as exc:
|
|
errors.append(str(exc))
|
|
return errors
|
|
|
|
example_set = set(example_keys)
|
|
env_set = set(env_keys)
|
|
|
|
missing_in_example = [k for k in env_keys if k not in example_set]
|
|
|
|
if missing_in_example:
|
|
rel_example = example_path.relative_to(REPO_ROOT)
|
|
errors.append(f"{rel_example}: missing keys from .env schema: {', '.join(missing_in_example)}")
|
|
|
|
return errors
|
|
|
|
|
|
def fix_env_sync(env_path: Path, example_path: Path) -> tuple[bool, str | None]:
|
|
"""Update .env.example: .env defines schema, .env.example provides defaults."""
|
|
rel_env = env_path.relative_to(REPO_ROOT)
|
|
rel_example = example_path.relative_to(REPO_ROOT)
|
|
|
|
if not env_path.exists():
|
|
return False, f"Missing .env file (schema source): {rel_env}"
|
|
|
|
try:
|
|
_, example_values = parse_env_key_values(example_path) if example_path.exists() else ([], {})
|
|
desired = merge_env_into_example(env_path, example_values)
|
|
except EnvParseError as exc:
|
|
return False, str(exc)
|
|
|
|
current = example_path.read_text(encoding="utf-8") if example_path.exists() else None
|
|
if current == desired:
|
|
return False, None
|
|
|
|
example_path.write_text(desired, encoding="utf-8")
|
|
if current is None:
|
|
return True, f"Created {rel_example} from {rel_env} schema"
|
|
return True, f"Synced {rel_example} to match {rel_env}"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--fix",
|
|
action="store_true",
|
|
help="Update .env.example from .env schema, keeping default values",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
pairs = discover_pairs(REPO_ROOT)
|
|
if not pairs:
|
|
print("No .env.example files found.")
|
|
return 0
|
|
|
|
if args.fix:
|
|
changed: list[str] = []
|
|
errors: list[str] = []
|
|
|
|
for example_path, env_path in pairs:
|
|
did_change, message = fix_env_sync(env_path, example_path)
|
|
if message:
|
|
if did_change:
|
|
changed.append(message)
|
|
else:
|
|
errors.append(message)
|
|
|
|
if changed:
|
|
print("Synced .env.example from .env schema:\n")
|
|
for item in changed:
|
|
print(f"- {item}")
|
|
print("\nRe-stage changed .env.example files and commit again.")
|
|
return 1
|
|
|
|
if errors:
|
|
print("Environment file sync failed:\n")
|
|
for err in errors:
|
|
print(f"- {err}")
|
|
return 1
|
|
|
|
print(f"Environment files in sync ({len(pairs)} checked).")
|
|
return 0
|
|
|
|
all_errors: list[str] = []
|
|
for example_path, env_path in pairs:
|
|
all_errors.extend(compare_env(example_path, env_path))
|
|
|
|
if all_errors:
|
|
print("Environment file validation failed:\n")
|
|
for err in all_errors:
|
|
print(f"- {err}")
|
|
return 1
|
|
|
|
print(f"Environment files look good ({len(pairs)} checked).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|