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 TrueRegistration 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:
- Creates a
CheckRegistryinstance - Registers built-in checks
- Calls
importlib.metadata.entry_points(group="pycmdcheck.checks") - For each entry point found, loads and calls the register function
- 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 loadedAvailable 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.PASSEDTesting 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 registryRunning 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-missingComplete 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 TruePublishing Plugins
Prepare for PyPI
- Choose a name: Follow the convention
pycmdcheck-*for discoverability - Add metadata: Include description, keywords, and classifiers in pyproject.toml
- Write documentation: Create a README explaining your checks
- 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/*Recommended classifiers
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Framework :: pycmdcheck",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Quality Assurance",
]Best Practices
Check Design
- One check, one concern: Each check should verify a single thing
- Clear IDs: Use a unique prefix for your plugin (e.g., “MY001”, “MY002”)
- Appropriate severity: Use ERROR for critical issues, WARNING for important ones, NOTE for suggestions
- Helpful messages: Include actionable hints with every failure
- Fast execution: Avoid expensive operations; cache when possible
Code Quality
- Type annotations: Use type hints throughout your code
- Docstrings: Document all public classes and methods
- Error handling: Handle edge cases gracefully (missing files, parse errors)
- Testing: Aim for high test coverage of your checks
Compatibility
- Minimal dependencies: Only depend on pycmdcheck and stdlib when possible
- Version constraints: Specify minimum pycmdcheck version
- Python version: Support Python 3.11+
Troubleshooting
Plugin Not Discovered
- Verify your package is installed:
pip list | grep pycmdcheck - Check entry point syntax in pyproject.toml
- Ensure the module path is correct and importable
- Check for import errors:
python -c "from your_package import register"
Check Not Running
- Verify the check is registered:
pycmdcheck list | grep YOUR_ID - Check that required fixtures are available
- Ensure check ID doesn’t conflict with built-in checks
Import Errors
- Make sure pycmdcheck is a dependency
- Check for circular imports in your module
- Verify all imports use correct paths