#!/usr/bin/env python3 from __future__ import annotations import argparse import sys from pathlib import Path from typing import Any try: import yaml except ImportError as exc: # pragma: no cover raise SystemExit( "Missing dependency: PyYAML. Install it with `python -m pip install pyyaml`." ) from exc def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Render docker compose .env from deploy config.yaml" ) parser.add_argument( "--input", default="deploy/docker/config.yaml", help="Path to config.yaml (default: deploy/docker/config.yaml)", ) parser.add_argument( "--output", default="deploy/docker/.env", help="Output dotenv file path (default: deploy/docker/.env)", ) parser.add_argument( "--section", default="compose_env", help="Top-level mapping section to export (default: compose_env)", ) parser.add_argument( "--stdout", action="store_true", help="Print rendered dotenv to stdout instead of writing file", ) return parser.parse_args() def load_config(path: Path) -> dict[str, Any]: if not path.exists(): raise SystemExit(f"Config file not found: {path}") data = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise SystemExit("config.yaml root must be a mapping/object") return data def encode_env_value(value: Any) -> str: if value is None: return '""' if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return str(value) if not isinstance(value, str): raise SystemExit(f"compose_env only supports scalar values, got: {type(value).__name__}") if value == "": return '""' needs_quotes = any(ch in value for ch in [' ', '#', '"', "'", '\t', '\n', '\r']) or value.startswith('$') if not needs_quotes: return value escaped = ( value.replace('\\', '\\\\') .replace('"', '\\"') .replace('\n', '\\n') .replace('\r', '\\r') .replace('\t', '\\t') ) return f'"{escaped}"' def render_env(section_name: str, values: dict[str, Any], source_path: Path) -> str: lines = [ f"# Generated from {source_path.as_posix()}::{section_name}", "# Do not edit this file directly; edit config.yaml and re-render.", "", ] for key, value in values.items(): if not isinstance(key, str) or not key: raise SystemExit(f"Invalid env key: {key!r}") lines.append(f"{key}={encode_env_value(value)}") lines.append("") return "\n".join(lines) def main() -> int: args = parse_args() source_path = Path(args.input) output_path = Path(args.output) data = load_config(source_path) section = data.get(args.section) if not isinstance(section, dict): raise SystemExit( f"Section `{args.section}` must exist in config.yaml and must be a mapping/object" ) rendered = render_env(args.section, section, source_path) if args.stdout: sys.stdout.write(rendered) return 0 output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(rendered, encoding="utf-8", newline="\n") print(f"Wrote {output_path}") return 0 if __name__ == "__main__": raise SystemExit(main())