Writing a custom check¶
Doctor's registry is entry-point-based. Any PyPI package (including your
own myproject/checks/*.py) can contribute checks — no core changes
required.
Minimal check¶
# myproject/checks/cache_keys.py
from django_doctor import Check, Finding, Severity
class CacheKeyPrefixCheck(Check):
"""Refuse cache writes with no key prefix (multi-tenant safety)."""
id = "cache_keys"
description = "Every cache.set() must use a prefixed key"
order = 80 # runs late; pure introspection, cheap
def run(self, project):
import ast, pathlib
for path in pathlib.Path("myproject").rglob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and getattr(node.func, "attr", "") == "set"
and not _starts_with_prefix(node)
):
yield Finding(
severity=Severity.WARNING,
check_id=self.id,
rule="cache_keys.unprefixed",
location=f"{path}:{node.lineno}",
message="cache.set() without a 'tenant_' prefix",
fix_hint="Use `cache.set(f'tenant_{tid}:{key}', …)`",
)
Register it:
# pyproject.toml
[project.entry-points."django_doctor.checks"]
cache_keys = "myproject.checks.cache_keys:CacheKeyPrefixCheck"
After pip install -e ., ./manage.py doctor will discover and run it.
Anatomy of a Check¶
class Check:
id: str # unique, short, lowercase
description: str # one-sentence summary for reports
order: int = 100 # lower = earlier; cheap checks should be low
slow: bool = False # set True if the check takes more than a few seconds
def run(self, project: Project) -> Iterable[Finding]:
...
def get_config(self, project: Project) -> dict:
# Returns whatever the user configured under
# `[tool.django-doctor.<id>]` in pyproject.toml.
...
Finding fields¶
@dataclass
class Finding:
severity: Severity # INFO / WARNING / ERROR / CRITICAL
check_id: str # auto-filled from Check.id
message: str # what happened
rule: str = "" # sub-rule inside a check (fnmatch-able)
location: str = "" # "myapp/models.py:42" or "url:name"
fix_hint: str = "" # plain-English remediation
extra: dict = {} # anything you want in JSON output
Pick rule names users can match against ignore patterns in
pyproject.toml. Example: "cache_keys.unprefixed" lets them silence via
ignore = ["cache_keys:cache_keys.unprefixed"].
Reading user configuration¶
def run(self, project):
config = self.get_config(project) # dict
required_prefix = config.get("prefix", "tenant_")
skip_patterns = config.get("skip", [])
...
Testing your check¶
# tests/test_cache_keys.py
import pytest
from django_doctor import Severity
from django_doctor.registry import Project
from myproject.checks.cache_keys import CacheKeyPrefixCheck
def test_flags_unprefixed_cache_set(tmp_path, monkeypatch):
(tmp_path / "myproject").mkdir()
(tmp_path / "myproject" / "bad.py").write_text("import django\ncache.set('x', 1)")
monkeypatch.chdir(tmp_path)
findings = list(CacheKeyPrefixCheck().run(Project()))
assert any(
f.severity is Severity.WARNING
and f.rule == "cache_keys.unprefixed"
for f in findings
)