78 lines
3.3 KiB
Python
78 lines
3.3 KiB
Python
"""Static deployment-readiness checks (these do NOT deploy anything to Heroku)."""
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
# Top-level keys documented for app.json (https://devcenter.heroku.com/articles/app-json-schema)
|
|
APP_JSON_KEYS = {
|
|
"name", "description", "keywords", "website", "repository", "logo", "success_url", "scripts", "env",
|
|
"formation", "image", "addons", "buildpacks", "environments", "stack",
|
|
}
|
|
ENV_KEYS = {"description", "value", "required", "generator"}
|
|
|
|
|
|
def _config_vars() -> set[str]:
|
|
source = (ROOT / "config.py").read_text()
|
|
return set(re.findall(r'r\.\w+\(\s*"([A-Z0-9_]+)"', source)) | set(re.findall(r'env\.get\("([A-Z0-9_]+)"\)', source))
|
|
|
|
|
|
def _app_json() -> dict:
|
|
return json.loads((ROOT / "app.json").read_text())
|
|
|
|
|
|
def test_app_json_structure():
|
|
manifest = _app_json()
|
|
assert set(manifest) <= APP_JSON_KEYS
|
|
assert len(manifest["name"]) <= 30 and manifest["description"]
|
|
assert manifest["buildpacks"] == [{"url": "heroku/python"}]
|
|
assert manifest["formation"] == {"worker": {"quantity": 1, "size": "basic"}}
|
|
assert manifest["addons"][0]["plan"].startswith("heroku-postgresql:")
|
|
for name, spec in manifest["env"].items():
|
|
assert set(spec) <= ENV_KEYS, name
|
|
assert spec["description"], name
|
|
assert isinstance(spec.get("required", False), bool), name
|
|
if "value" in spec:
|
|
assert isinstance(spec["value"], str), name
|
|
|
|
|
|
def test_app_json_only_lists_supported_variables():
|
|
supported = _config_vars()
|
|
unsupported = set(_app_json()["env"]) - supported
|
|
assert not unsupported, f"app.json lists variables the app does not read: {unsupported}"
|
|
|
|
|
|
def test_env_example_documents_every_variable():
|
|
documented = set(re.findall(r"^([A-Z0-9_]+)=", (ROOT / ".env.example").read_text(), re.MULTILINE))
|
|
supported = _config_vars() - {"DYNO"} # DYNO is set by Heroku itself
|
|
assert supported - documented == set()
|
|
assert documented - supported == set()
|
|
|
|
|
|
def test_procfile_matches_entry_point():
|
|
procfile = (ROOT / "Procfile").read_text().strip().splitlines()
|
|
assert procfile == ["worker: python main.py run"]
|
|
assert set(_app_json()["formation"]) == {line.split(":")[0] for line in procfile}
|
|
main_source = (ROOT / "main.py").read_text()
|
|
assert 'sub.add_parser("run"' in main_source and "signal.SIGTERM" in main_source
|
|
|
|
|
|
def test_runtime_and_requirements():
|
|
assert (ROOT / ".python-version").read_text().strip() == "3.12"
|
|
assert not (ROOT / "runtime.txt").exists() # deprecated in favour of .python-version
|
|
reqs = (ROOT / "requirements.txt").read_text()
|
|
for package in ("Telethon", "google-api-python-client", "google-auth", "google-auth-oauthlib", "psycopg[binary]", "python-dotenv"):
|
|
assert re.search(rf"^{re.escape(package)}==", reqs, re.MULTILINE), package
|
|
|
|
|
|
def test_gitignore_protects_secrets_and_state():
|
|
ignored = (ROOT / ".gitignore").read_text().splitlines()
|
|
for pattern in (".env", "*.session", "credentials/", "token.json", "session_string.txt", "data/", "downloads/", "*.db"):
|
|
assert pattern in ignored, pattern
|
|
|
|
|
|
def test_readme_deploy_button():
|
|
readme = (ROOT / "README.md").read_text()
|
|
assert "https://www.herokucdn.com/deploy/button.svg" in readme
|
|
assert re.search(r"\]\(https://heroku\.com/deploy\?template=https://github\.com/[^)\s]+\)", readme)
|