Skip to content

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", [])
    ...
[tool.django-doctor.cache_keys]
prefix = "tenant_"
skip   = ["tests.*"]

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
    )