""" check_env.py — resolve and report the database configuration WITHOUT connecting. Answers "is my .env actually valid?" before you run alembic. Stdlib only, so it works before `uv sync`. Passwords are never printed. python3 scripts/check_env.py Common trap this catches: DATABASE_URL is a URL, so a password containing @ : / ? # % must be percent-encoded — Neon's own connection string is already encoded correctly, so this usually only bites if the password was edited by hand. """ from __future__ import annotations import sys from pathlib import Path from urllib.parse import unquote, urlsplit BACKEND = Path(__file__).resolve().parent.parent REPO_ROOT = BACKEND.parent NEEDS_ENCODING = set("@:/?#[]% ") def load_env_file(path: Path) -> dict[str, str]: """Minimal .env reader — same precedence rule as config.py (first wins).""" values: dict[str, str] = {} if not path.exists(): return values for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") val = val.strip() if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'": val = val[1:-1] values.setdefault(key.strip(), val) return values def main() -> int: env = load_env_file(BACKEND / ".env") for k, v in load_env_file(REPO_ROOT / ".env").items(): env.setdefault(k, v) if not (BACKEND / ".env").exists(): print(f"ERROR: {BACKEND / '.env'} does not exist. Copy .env.example to .env.") return 1 problems: list[str] = [] url = env.get("DATABASE_URL", "").strip() print(f"backend .env : {BACKEND / '.env'}") print(f"root .env : {REPO_ROOT / '.env'}" f"{'' if (REPO_ROOT / '.env').exists() else ' (absent)'}") print() if not url: problems.append("DATABASE_URL is empty — paste the Neon connection string in") else: parts = urlsplit(url) driver, host, port = parts.scheme, parts.hostname, parts.port user = unquote(parts.username or "") raw_pw = parts.password or "" db = parts.path.lstrip("/") query = parts.query print(f"driver : {driver}") print(f"host : {host}") print(f"port : {port or 5432}") print(f"user : {user}") print(f"password : {'set (' + str(len(raw_pw)) + ' chars as written)' if raw_pw else 'MISSING'}") print(f"database : {db}") print(f"query : {query or '(none)'}") if driver != "postgresql+psycopg": problems.append( f"driver is {driver!r}; this backend expects postgresql+psycopg (psycopg v3)" ) if not host: problems.append("no host in DATABASE_URL") if host and "neon.tech" not in host: print(" (host does not look like a Neon endpoint — fine if intentional)") if not user: problems.append("no user in DATABASE_URL") if not raw_pw: problems.append("no password in DATABASE_URL") if not db: problems.append("no database name in DATABASE_URL (the path after the host)") if "sslmode=require" not in query and "sslmode=verify-full" not in query: problems.append("sslmode=require missing — Neon refuses plain-text connections") # A raw special character in the password truncates the URL silently. decoded = unquote(raw_pw) if decoded != raw_pw and any(c in NEEDS_ENCODING for c in decoded): print(" (password contains percent-encoded characters — good)") elif any(c in NEEDS_ENCODING for c in raw_pw): problems.append( "password contains a character that must be percent-encoded " f"({''.join(sorted(set(c for c in raw_pw if c in NEEDS_ENCODING)))}) — " "urlsplit has probably mis-parsed this URL" ) print() print("service settings") for key, default in ( ("PUBLIC_BASE_URL", "http://127.0.0.1:8000"), ("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000"), ("SCREENSHOT_DIR", "screenshots"), ): val = env.get(key, "").strip() print(f" {key:16s}: {val or default}{' (default)' if not val else ''}") print(f" {'API_KEY':16s}: {'set — X-API-Key required' if env.get('API_KEY','').strip() else 'empty — auth disabled (fine for localhost)'}") print() print("third-party keys (presence only)") for key in ("GEMINI_API_KEY", "HUNTER_API_KEY", "SIGNALHIRE_API_KEY", "APOLLO_API_KEY", "CALENDAR_LINK"): print(f" {key:20s}: {'set' if env.get(key, '').strip() else 'not set'}") print() if problems: print(f"{len(problems)} problem(s):") for p in problems: print(f" - {p}") return len(problems) print("config looks valid. Next: uv run alembic upgrade head") return 0 if __name__ == "__main__": sys.exit(main())