v0.150.0
  1from __future__ import annotations
  2
  3from collections.abc import Callable, Generator
  4from typing import Any, TypeVar
  5
  6from plain.runtime import settings
  7
  8from .checks import PreflightCheck
  9from .results import PreflightResult, unused_silenced_results
 10
 11T = TypeVar("T")
 12
 13UNUSED_SILENCES_CHECK_NAME = "preflight.unused_silences"
 14
 15
 16class CheckUnusedSilences(PreflightCheck):
 17    """Reports `PREFLIGHT_SILENCED_RESULTS` entries that matched nothing.
 18
 19    An unused entry is either a typo or stale — the issue it silenced has
 20    been fixed. Not registered like a normal check: it needs every other
 21    check's results, so the registry runs it last with the full run's
 22    results, and only on a full run (deploy checks included) — a partial
 23    run skips checks whose entries would then look unused.
 24    """
 25
 26    def __init__(self, run_results: list[PreflightResult]) -> None:
 27        self.run_results = run_results
 28
 29    def run(self) -> list[PreflightResult]:
 30        return [
 31            PreflightResult(
 32                fix=f"Silenced result {entry!r} matched nothing in this run. "
 33                "Remove it from PREFLIGHT_SILENCED_RESULTS or fix the typo.",
 34                obj=entry,
 35                id="preflight.unused_silence",
 36                warning=True,
 37            )
 38            for entry in unused_silenced_results(self.run_results)
 39        ]
 40
 41
 42class CheckRegistry:
 43    def __init__(self) -> None:
 44        self.checks: dict[
 45            str, tuple[type[Any], bool]
 46        ] = {}  # name -> (check_class, deploy)
 47
 48    def register_check(
 49        self, check_class: type[Any], name: str, deploy: bool = False
 50    ) -> None:
 51        """Register a check class with a unique name."""
 52        if name in self.checks:
 53            raise ValueError(f"Check {name} already registered")
 54        self.checks[name] = (check_class, deploy)
 55
 56    def run_checks(
 57        self,
 58        include_deploy_checks: bool = False,
 59    ) -> Generator[tuple[type[Any], str, list[PreflightResult]]]:
 60        """
 61        Run all registered checks and yield (check_class, name, results) tuples.
 62        """
 63        # Validate silenced check names (the unused-silences check isn't in
 64        # self.checks — the registry emits it itself — but it's silenceable
 65        # like any other check)
 66        silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
 67        known_checks = set(self.checks.keys()) | {UNUSED_SILENCES_CHECK_NAME}
 68        unknown_silenced = set(silenced_checks) - known_checks
 69        if unknown_silenced:
 70            unknown_names = ", ".join(sorted(unknown_silenced))
 71            raise ValueError(
 72                f"Unknown check names in PREFLIGHT_SILENCED_CHECKS: {unknown_names}. "
 73                "Check for typos or remove outdated check names."
 74            )
 75
 76        all_results: list[PreflightResult] = []
 77
 78        for name, (check_class, deploy) in sorted(self.checks.items()):
 79            # Skip silenced checks
 80            if name in silenced_checks:
 81                continue
 82
 83            # Skip deployment checks if not requested
 84            if deploy and not include_deploy_checks:
 85                continue
 86
 87            # Instantiate and run check
 88            check = check_class()
 89            results = check.run()
 90            all_results.extend(results)
 91            yield check_class, name, results
 92
 93        if include_deploy_checks and UNUSED_SILENCES_CHECK_NAME not in silenced_checks:
 94            check = CheckUnusedSilences(all_results)
 95            yield CheckUnusedSilences, UNUSED_SILENCES_CHECK_NAME, check.run()
 96
 97
 98checks_registry = CheckRegistry()
 99
100
101def register_check(name: str, *, deploy: bool = False) -> Callable[[type[T]], type[T]]:
102    """
103    Decorator to register a check class.
104
105    Usage:
106        @register_check("security.secret_key", deploy=True)
107        class CheckSecretKey(PreflightCheck):
108            pass
109
110        @register_check("files.upload_temp_dir")
111        class CheckUploadTempDir(PreflightCheck):
112            pass
113    """
114
115    def wrapper(cls: type[T]) -> type[T]:
116        checks_registry.register_check(cls, name=name, deploy=deploy)
117        return cls
118
119    return wrapper
120
121
122run_checks = checks_registry.run_checks
123
124# Cached error/warning counts — populated on first call, refreshed by
125# PreflightView when the full page is viewed.
126_check_counts: dict[str, int] | None = None
127
128
129def get_check_counts() -> dict[str, int]:
130    """Return ``{"errors": N, "warnings": N}``, caching for the process lifetime."""
131    global _check_counts
132
133    if _check_counts is not None:
134        return _check_counts
135
136    from plain.packages import packages_registry
137
138    packages_registry.autodiscover_modules("preflight", include_app=True)
139
140    warning_count = 0
141    error_count = 0
142
143    for _check_class, _name, results in run_checks(
144        include_deploy_checks=not settings.DEBUG
145    ):
146        visible = [r for r in results if not r.is_silenced()]
147        if not visible:
148            continue
149        if any(not r.warning for r in visible):
150            error_count += 1
151        else:
152            warning_count += 1
153
154    _check_counts = {"errors": error_count, "warnings": warning_count}
155    return _check_counts
156
157
158def set_check_counts(*, errors: int, warnings: int) -> None:
159    """Update the cached counts (called by PreflightView after running full checks)."""
160    global _check_counts
161    _check_counts = {"errors": errors, "warnings": warnings}