#!/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())