Files
Magent/scripts/check_environment_docs.py
T

133 lines
5.6 KiB
Python

"""Check the environment reference against source without importing application settings.
Only tracked-source locations are inspected. Deployment .env files, process
environment values and runtime data are never opened or evaluated.
"""
import ast
from dataclasses import dataclass
import json
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z")
@dataclass(frozen=True)
class Setting:
names: tuple[str, ...]
default: str
def settings_inventory(source: str) -> list[Setting]:
tree = ast.parse(source.lstrip("\ufeff"))
settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings")
result = []
for node in settings.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
name = node.target.id
names = (name.upper(),)
default = node.value
if isinstance(default, ast.Call):
arguments = {keyword.arg: keyword.value for keyword in default.keywords}
alias = arguments.get("validation_alias")
if isinstance(alias, ast.Constant):
names = (alias.value,)
elif isinstance(alias, ast.Call):
names = tuple(ast.literal_eval(argument) for argument in alias.args)
default = arguments.get("default")
if isinstance(default, ast.Name):
value = "@" + default.id
else:
value = json.dumps(ast.literal_eval(default), ensure_ascii=True)
result.append(Setting(names, value))
return result
def python_environment_names(source: str) -> set[str]:
names = set()
for node in ast.walk(ast.parse(source.lstrip("\ufeff"))):
argument = None
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args:
receiver = ast.unparse(node.func.value)
if (node.func.attr == "getenv" and receiver == "os") or (
node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"}
):
argument = node.args[0]
elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in {
"os.environ", "environ", "environment", "prepared"
}:
argument = node.slice
if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value):
names.add(argument.value)
return names
def runtime_environment_names(root: Path) -> set[str]:
names = set()
sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")]
for path in sources:
names.update(python_environment_names(path.read_text(encoding="utf-8")))
javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"),
*root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"]
for path in javascript:
if ".test." not in path.name:
names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"),
*root.glob(".gitea/workflows/*.yml")]
for path in deployment:
source = path.read_text(encoding="utf-8")
names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source))
names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source))
names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source))
# These are shell syntax/builtins, not Magent configuration options.
names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"})
dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
for line in dockerfile.splitlines():
if line.startswith(("ENV ", "ARG ")):
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8")
for line in supervisor.splitlines():
if line.startswith("environment="):
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
return names
def check_documentation(root: Path = ROOT) -> tuple[list[str], int]:
document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8")
documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document))
settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8"))
required = runtime_environment_names(root) | {name for setting in settings for name in setting.names}
errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)]
defaults = {}
for line in document.splitlines():
cells = line.split("|")
if len(cells) >= 4 and cells[1].strip().startswith("`"):
for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]):
defaults[name] = cells[2].strip().strip("`")
for setting in settings:
for name in setting.names:
if name in documented and defaults.get(name) != setting.default:
errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}")
return errors, len(required)
def main() -> int:
errors, count = check_documentation()
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.")
return 0
if __name__ == "__main__":
raise SystemExit(main())