Plugin Development Guide

This guide explains how to create plugins for pycmdcheck. Plugins allow you to extend pycmdcheck with custom checks, fixtures, profiles, and output formats.

Overview

pycmdcheck uses Python’s entry points mechanism for plugin discovery. When pycmdcheck runs, it scans for installed packages that declare entry points in the pycmdcheck.checks group and automatically loads any custom checks they provide.

The plugin system supports four extension points:

Entry Point Group Purpose
pycmdcheck.checks Custom check classes
pycmdcheck.fixtures Custom fixtures for checks
pycmdcheck.profiles Custom check profiles
pycmdcheck.output Custom output formatters

Creating a Plugin Package

Project Structure

A typical pycmdcheck plugin has this structure:

pycmdcheck-my-plugin/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── pycmdcheck_my_plugin/
│       ├── __init__.py
│       └── checks.py
└── tests/
    └── test_checks.py

pyproject.toml Configuration

The key to making your plugin discoverable is the entry points configuration in pyproject.toml:

[project]
name = "pycmdcheck-my-plugin"
version = "0.1.0"
description = "My custom checks for pycmdcheck"
requires-python = ">=3.11"
dependencies = [
    "pycmdcheck>=0.1.0",
]

[project.entry-points."pycmdcheck.checks"]
my_plugin = "pycmdcheck_my_plugin.checks:register"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The entry point format is:

  • Group: pycmdcheck.checks - tells pycmdcheck this is a check plugin
  • Name: my_plugin - a unique identifier for your plugin
  • Value: pycmdcheck_my_plugin.checks:register - the module path and function name

Check Class Structure

Each check class must implement the CheckProtocol interface with specific class-level attributes and a check() classmethod.

Required Attributes

Attribute Type Description
id ClassVar[str] Unique check identifier (e.g., “MY001”)
group ClassVar[str] Check group name (e.g., “my-checks”)
name ClassVar[str] Human-readable check name
severity ClassVar[Severity] Default severity: ERROR, WARNING, or NOTE
requires ClassVar[set[str]] Set of fixture names the check needs

Optional Attributes

Attribute Type Description
doc_url ClassVar[str] URL to documentation for this check

The check() Method

The check() classmethod receives fixtures as keyword arguments and must return a CheckResult:

from pathlib import Path
from typing import ClassVar

from pycmdcheck.core.models import CheckResult, Severity


class MyCheck:
    """Check for something specific (MY001).

    Docstring explaining what this check does and why it matters.
    """

    id: ClassVar[str] = "MY001"
    group: ClassVar[str] = "my-checks"
    name: ClassVar[str] = "MyCheck"
    severity: ClassVar[Severity] = Severity.WARNING
    requires: ClassVar[set[str]] = {"root"}
    doc_url: ClassVar[str] = "https://example.com/docs/MY001"

    @classmethod
    def check(cls, root: Path, **kwargs) -> CheckResult:
        """Check for the specific condition.

        Args:
            root: Path to the package root directory
            **kwargs: Additional fixtures (unused by this check)

        Returns:
            CheckResult indicating pass/fail status
        """
        # Your check logic here
        config_file = root / "my_config.toml"

        if config_file.exists():
            return CheckResult.passed(
                check_id=cls.id,
                check_name=cls.name,
                group=cls.group,
                severity=cls.severity,
                url=cls.doc_url,
            )

        return CheckResult.failed(
            check_id=cls.id,
            check_name=cls.name,
            group=cls.group,
            severity=cls.severity,
            message="Missing my_config.toml file",
            hint="Create a my_config.toml file in the project root",
            url=cls.doc_url,
        )

CheckResult Factory Methods

CheckResult provides factory methods for creating results:

Method When to Use
CheckResult.passed(...) Check condition is satisfied
CheckResult.failed(...) Check condition is not satisfied
CheckResult.skipped(...) Check was deliberately skipped
CheckResult.not_applicable(...) Check doesn’t apply to this package
CheckResult.errored(...) Check itself failed to run

Fixable Checks

Checks can optionally implement a fix() method to provide auto-fix capability:

class MyFixableCheck:
    """A check that can auto-fix issues (MY002)."""

    id: ClassVar[str] = "MY002"
    group: ClassVar[str] = "my-checks"
    name: ClassVar[str] = "MyFixableCheck"
    severity: ClassVar[Severity] = Severity.NOTE
    requires: ClassVar[set[str]] = {"root"}

    @classmethod
    def check(cls, root: Path, **kwargs) -> CheckResult:
        if (root / ".my-marker").exists():
            return CheckResult.passed(
                check_id=cls.id,
                check_name=cls.name,
                group=cls.group,
            )
        return CheckResult.failed(
            check_id=cls.id,
            check_name=cls.name,
            group=cls.group,
            severity=cls.severity,
            message="Missing .my-marker file",
            hint="Run with --fix to create the file",
        )

    @classmethod
    def fix(cls, root: Path, **kwargs) -> bool:
        """Create the .my-marker file.

        Args:
            root: Path to the package root directory
            **kwargs: Additional fixtures (unused)

        Returns:
            True if fix was applied, False if already fixed
        """
        marker_path = root / ".my-marker"
        if marker_path.exists():
            return False

        marker_path.write_text("")
        return True

Registration via Entry Points

The Register Function

Your plugin must provide a register function that accepts a CheckRegistry and adds your checks:

# src/pycmdcheck_my_plugin/checks.py

from pycmdcheck.core.registry import CheckRegistry

from .my_checks import MyCheck, MyOtherCheck, MyFixableCheck


def register(registry: CheckRegistry) -> None:
    """Register all checks from this plugin.

    Args:
        registry: The CheckRegistry to add checks to
    """
    registry.add(MyCheck)
    registry.add(MyOtherCheck)
    registry.add(MyFixableCheck)

How Discovery Works

When pycmdcheck starts, it:

  1. Creates a CheckRegistry instance
  2. Registers built-in checks
  3. Calls importlib.metadata.entry_points(group="pycmdcheck.checks")
  4. For each entry point found, loads and calls the register function
  5. Your checks are now available alongside built-in checks
# Internal implementation (for reference)
from importlib.metadata import entry_points

from pycmdcheck.core.registry import CheckRegistry

def discover_plugins(registry: CheckRegistry) -> list[str]:
    """Discover and load check plugins."""
    loaded: list[str] = []
    eps = entry_points(group="pycmdcheck.checks")

    for ep in eps:
        try:
            register_func = ep.load()
            register_func(registry)
            loaded.append(ep.name)
        except Exception:
            # Continue loading other plugins on failure
            pass

    return loaded

Available Fixtures

Fixtures provide data that checks commonly need. Declare which fixtures your check requires in the requires class attribute.

Built-in Fixtures

Fixture Name Type Description
root Path Path to the package root directory
pyproject PyprojectResult Parsed pyproject.toml data
package_name str The package name from pyproject.toml
package_version str The package version

Using Fixtures

class CheckUsingMultipleFixtures:
    """Check that uses multiple fixtures (MY003)."""

    id: ClassVar[str] = "MY003"
    group: ClassVar[str] = "my-checks"
    name: ClassVar[str] = "CheckUsingMultipleFixtures"
    severity: ClassVar[Severity] = Severity.NOTE
    requires: ClassVar[set[str]] = {"root", "pyproject", "package_name"}

    @classmethod
    def check(
        cls,
        root: Path,
        pyproject,
        package_name: str,
        **kwargs,
    ) -> CheckResult:
        """Check using multiple fixtures.

        Args:
            root: Path to the package root
            pyproject: PyprojectResult with parsed data
            package_name: The package name
            **kwargs: Additional fixtures
        """
        # Access pyproject data
        if pyproject.exists and pyproject.data:
            project = pyproject.data.get("project", {})
            description = project.get("description", "")

            if package_name.lower() in description.lower():
                return CheckResult.passed(
                    check_id=cls.id,
                    check_name=cls.name,
                    group=cls.group,
                )

        return CheckResult.failed(
            check_id=cls.id,
            check_name=cls.name,
            group=cls.group,
            severity=cls.severity,
            message=f"Description doesn't mention '{package_name}'",
            hint="Include the package name in the description",
        )

Testing Plugins

Basic Test Setup

Use pytest to test your checks:

# tests/test_checks.py

from pathlib import Path

import pytest

from pycmdcheck.core.models import CheckStatus
from pycmdcheck.core.registry import CheckRegistry

from pycmdcheck_my_plugin.checks import MyCheck, register


class TestMyCheck:
    """Tests for MyCheck."""

    def test_check_id(self):
        """Check ID should follow naming convention."""
        assert MyCheck.id == "MY001"
        assert MyCheck.group == "my-checks"

    def test_check_passes_when_config_exists(self, tmp_path: Path):
        """Check passes when my_config.toml exists."""
        (tmp_path / "my_config.toml").write_text("[settings]\n")

        result = MyCheck.check(root=tmp_path)

        assert result.status == CheckStatus.PASSED

    def test_check_fails_when_config_missing(self, tmp_path: Path):
        """Check fails when my_config.toml is missing."""
        result = MyCheck.check(root=tmp_path)

        assert result.status == CheckStatus.FAILED
        assert "my_config.toml" in result.message


class TestRegistration:
    """Tests for plugin registration."""

    def test_register_adds_all_checks(self):
        """Register function should add all plugin checks."""
        registry = CheckRegistry()

        register(registry)

        assert "MY001" in registry
        # Add assertions for other checks

    def test_checks_work_with_registry(self, tmp_path: Path):
        """Registered checks should work via registry."""
        registry = CheckRegistry()
        register(registry)

        (tmp_path / "my_config.toml").write_text("")

        check_class = registry.get("MY001")
        result = check_class.check(root=tmp_path)

        assert result.status == CheckStatus.PASSED

Testing with Mocked Entry Points

To test that your plugin integrates correctly with pycmdcheck’s discovery:

from unittest.mock import MagicMock, patch

from pycmdcheck.core.registry import CheckRegistry
from pycmdcheck.plugins import discover_plugins

from pycmdcheck_my_plugin.checks import register


def test_plugin_discovery_integration():
    """Plugin should be discoverable via entry points."""
    registry = CheckRegistry()

    mock_ep = MagicMock()
    mock_ep.name = "my_plugin"
    mock_ep.load.return_value = register

    with patch("pycmdcheck.plugins.entry_points") as mock_entry_points:
        mock_entry_points.return_value = [mock_ep]

        loaded = discover_plugins(registry)

    assert "my_plugin" in loaded
    assert "MY001" in registry

Running Tests

# Run all tests
pytest tests/ -v

# Run specific test file
pytest tests/test_checks.py -v

# Run with coverage
pytest tests/ --cov=pycmdcheck_my_plugin --cov-report=term-missing

Complete Example Plugin

Here’s a complete example of a simple plugin:

pyproject.toml

[project]
name = "pycmdcheck-security-extras"
version = "0.1.0"
description = "Additional security checks for pycmdcheck"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
dependencies = ["pycmdcheck>=0.1.0"]

[project.entry-points."pycmdcheck.checks"]
security_extras = "pycmdcheck_security_extras:register"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

src/pycmdcheck_security_extras/init.py

"""Additional security checks for pycmdcheck."""

from pycmdcheck.core.registry import CheckRegistry

from .checks import NoHardcodedSecrets, RequiresDependabot


def register(registry: CheckRegistry) -> None:
    """Register all security checks."""
    registry.add(NoHardcodedSecrets)
    registry.add(RequiresDependabot)

src/pycmdcheck_security_extras/checks.py

"""Security check implementations."""

from pathlib import Path
from typing import ClassVar
import re

from pycmdcheck.core.models import CheckResult, Severity


class NoHardcodedSecrets:
    """Check for hardcoded secrets in source files (SC101).

    Scans Python files for patterns that look like hardcoded
    API keys, passwords, or tokens.
    """

    id: ClassVar[str] = "SC101"
    group: ClassVar[str] = "security"
    name: ClassVar[str] = "NoHardcodedSecrets"
    severity: ClassVar[Severity] = Severity.ERROR
    requires: ClassVar[set[str]] = {"root"}

    # Patterns that might indicate hardcoded secrets
    SECRET_PATTERNS = [
        re.compile(r'api_key\s*=\s*["\'][^"\']{20,}["\']', re.IGNORECASE),
        re.compile(r'password\s*=\s*["\'][^"\']+["\']', re.IGNORECASE),
        re.compile(r'secret\s*=\s*["\'][^"\']{10,}["\']', re.IGNORECASE),
    ]

    @classmethod
    def check(cls, root: Path, **kwargs) -> CheckResult:
        """Scan for hardcoded secrets."""
        issues = []

        for py_file in root.rglob("*.py"):
            # Skip test files and virtual environments
            if "test" in py_file.parts or "venv" in py_file.parts:
                continue

            try:
                content = py_file.read_text()
                for pattern in cls.SECRET_PATTERNS:
                    if pattern.search(content):
                        issues.append(str(py_file.relative_to(root)))
                        break
            except (OSError, UnicodeDecodeError):
                continue

        if issues:
            return CheckResult.failed(
                check_id=cls.id,
                check_name=cls.name,
                group=cls.group,
                severity=cls.severity,
                message=f"Potential hardcoded secrets in: {', '.join(issues[:3])}",
                hint="Use environment variables or a secrets manager instead",
            )

        return CheckResult.passed(
            check_id=cls.id,
            check_name=cls.name,
            group=cls.group,
            severity=cls.severity,
        )


class RequiresDependabot:
    """Check for Dependabot configuration (SC102).

    Ensures the project has Dependabot configured for automated
    dependency updates.
    """

    id: ClassVar[str] = "SC102"
    group: ClassVar[str] = "security"
    name: ClassVar[str] = "RequiresDependabot"
    severity: ClassVar[Severity] = Severity.NOTE
    requires: ClassVar[set[str]] = {"root"}

    @classmethod
    def check(cls, root: Path, **kwargs) -> CheckResult:
        """Check for Dependabot configuration."""
        dependabot_path = root / ".github" / "dependabot.yml"
        alt_path = root / ".github" / "dependabot.yaml"

        if dependabot_path.exists() or alt_path.exists():
            return CheckResult.passed(
                check_id=cls.id,
                check_name=cls.name,
                group=cls.group,
                severity=cls.severity,
            )

        return CheckResult.failed(
            check_id=cls.id,
            check_name=cls.name,
            group=cls.group,
            severity=cls.severity,
            message="No Dependabot configuration found",
            hint="Create .github/dependabot.yml to automate dependency updates",
        )

    @classmethod
    def fix(cls, root: Path, **kwargs) -> bool:
        """Create Dependabot configuration."""
        github_dir = root / ".github"
        dependabot_path = github_dir / "dependabot.yml"

        if dependabot_path.exists():
            return False

        github_dir.mkdir(exist_ok=True)
        dependabot_path.write_text('''version: 2
updates:
  - package-ecosystem: "pip"
    directory: "/"
    schedule:
      interval: "weekly"
''')
        return True

Publishing Plugins

Prepare for PyPI

  1. Choose a name: Follow the convention pycmdcheck-* for discoverability
  2. Add metadata: Include description, keywords, and classifiers in pyproject.toml
  3. Write documentation: Create a README explaining your checks
  4. Add tests: Ensure your checks are well-tested

Build and Upload

# Install build tools
pip install build twine

# Build the package
python -m build

# Upload to PyPI (or TestPyPI first)
twine upload dist/*

Best Practices

Check Design

  1. One check, one concern: Each check should verify a single thing
  2. Clear IDs: Use a unique prefix for your plugin (e.g., “MY001”, “MY002”)
  3. Appropriate severity: Use ERROR for critical issues, WARNING for important ones, NOTE for suggestions
  4. Helpful messages: Include actionable hints with every failure
  5. Fast execution: Avoid expensive operations; cache when possible

Code Quality

  1. Type annotations: Use type hints throughout your code
  2. Docstrings: Document all public classes and methods
  3. Error handling: Handle edge cases gracefully (missing files, parse errors)
  4. Testing: Aim for high test coverage of your checks

Compatibility

  1. Minimal dependencies: Only depend on pycmdcheck and stdlib when possible
  2. Version constraints: Specify minimum pycmdcheck version
  3. Python version: Support Python 3.11+

Troubleshooting

Plugin Not Discovered

  1. Verify your package is installed: pip list | grep pycmdcheck
  2. Check entry point syntax in pyproject.toml
  3. Ensure the module path is correct and importable
  4. Check for import errors: python -c "from your_package import register"

Check Not Running

  1. Verify the check is registered: pycmdcheck list | grep YOUR_ID
  2. Check that required fixtures are available
  3. Ensure check ID doesn’t conflict with built-in checks

Import Errors

  1. Make sure pycmdcheck is a dependency
  2. Check for circular imports in your module
  3. Verify all imports use correct paths