126 lines
3.5 KiB
Python
126 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SETTINGS_PATH = ROOT / "app" / "settings.py"
|
|
ENV_EXAMPLE_PATH = ROOT / ".env.example"
|
|
|
|
SETTINGS_ENV_HELPERS = {"_env_bool", "_env_int", "_env_float", "_env_str"}
|
|
ALLOWED_ENV_EXAMPLE_EXTRAS = {
|
|
"POSTGRES_DB",
|
|
"POSTGRES_USER",
|
|
"POSTGRES_PASSWORD",
|
|
"TEST_DATABASE_URL",
|
|
"SCHOLARR_IMAGE",
|
|
"APP_HOST",
|
|
"APP_PORT",
|
|
"APP_HOST_PORT",
|
|
"APP_RELOAD",
|
|
"FRONTEND_HOST_PORT",
|
|
"CHOKIDAR_USEPOLLING",
|
|
"VITE_DEV_API_PROXY_TARGET",
|
|
"MIGRATE_ON_START",
|
|
"BOOTSTRAP_ADMIN_ON_START",
|
|
"BOOTSTRAP_ADMIN_EMAIL",
|
|
"BOOTSTRAP_ADMIN_PASSWORD",
|
|
"BOOTSTRAP_ADMIN_FORCE_PASSWORD",
|
|
"DB_WAIT_TIMEOUT_SECONDS",
|
|
"DB_WAIT_INTERVAL_SECONDS",
|
|
}
|
|
|
|
|
|
def _call_name(node: ast.AST) -> str | None:
|
|
if not isinstance(node, ast.Call):
|
|
return None
|
|
func = node.func
|
|
if isinstance(func, ast.Name):
|
|
return func.id
|
|
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
return f"{func.value.id}.{func.attr}"
|
|
return None
|
|
|
|
|
|
def _first_str_arg(node: ast.Call) -> str | None:
|
|
if not node.args:
|
|
return None
|
|
first = node.args[0]
|
|
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
return first.value
|
|
return None
|
|
|
|
|
|
def _settings_env_names() -> set[str]:
|
|
tree = ast.parse(SETTINGS_PATH.read_text(encoding="utf-8"))
|
|
names: set[str] = set()
|
|
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
call_name = _call_name(node)
|
|
if call_name is None:
|
|
continue
|
|
if call_name != "os.getenv" and call_name not in SETTINGS_ENV_HELPERS:
|
|
continue
|
|
env_name = _first_str_arg(node)
|
|
if not env_name:
|
|
continue
|
|
names.add(env_name)
|
|
return names
|
|
|
|
|
|
def _env_example_names() -> tuple[set[str], set[str]]:
|
|
names: set[str] = set()
|
|
duplicates: set[str] = set()
|
|
|
|
for raw_line in ENV_EXAMPLE_PATH.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
key = line.split("=", 1)[0].strip()
|
|
if not key:
|
|
continue
|
|
if key in names:
|
|
duplicates.add(key)
|
|
names.add(key)
|
|
return names, duplicates
|
|
|
|
|
|
def main() -> int:
|
|
settings_names = _settings_env_names()
|
|
env_names, duplicate_names = _env_example_names()
|
|
|
|
missing = sorted(settings_names - env_names)
|
|
unknown = sorted(env_names - settings_names - ALLOWED_ENV_EXAMPLE_EXTRAS)
|
|
duplicates = sorted(duplicate_names)
|
|
|
|
if missing or unknown or duplicates:
|
|
print("Environment contract check failed.")
|
|
if missing:
|
|
print("Missing from .env.example (referenced in app/settings.py):")
|
|
for name in missing:
|
|
print(f"- {name}")
|
|
if unknown:
|
|
print("Unknown keys in .env.example (not in app/settings.py or allowlist):")
|
|
for name in unknown:
|
|
print(f"- {name}")
|
|
if duplicates:
|
|
print("Duplicate keys in .env.example:")
|
|
for name in duplicates:
|
|
print(f"- {name}")
|
|
return 1
|
|
|
|
print(
|
|
"Environment contract check passed: "
|
|
f"{len(settings_names)} settings keys + {len(ALLOWED_ENV_EXAMPLE_EXTRAS)} allowed extras."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|