Architecture

pycmdcheck Architecture

This document describes the internal architecture of pycmdcheck, including the core modules and how they interact.

Module Overview

pycmdcheck/
├── core/                 # Core infrastructure
│   ├── runner.py         # Check execution engine
│   ├── registry.py       # Check registry
│   ├── errors.py         # Exception hierarchy
│   ├── subprocess_helpers.py  # Command execution
│   ├── file_helpers.py   # File I/O utilities
│   ├── ast_helpers.py    # AST visitor base classes
│   ├── ast_cache.py      # AST caching
│   ├── progress.py       # Progress reporting
│   └── check_config.py   # Typed check configurations
├── checks/               # Check implementations
│   ├── structure.py      # Package structure checks
│   ├── metadata.py       # Package metadata checks
│   ├── code_quality/     # Code quality checks (split into submodules)
│   ├── tests.py          # Test suite checks
│   └── ...
├── output/               # Output formatters
│   ├── base.py           # BaseFormatter abstract class
│   ├── constants.py      # Shared constants
│   ├── markdown_utils.py # Markdown utilities
│   └── ...
├── cli/                  # CLI commands
│   ├── commands/         # Main commands (check, fix, list, etc.)
│   └── groups/           # Command groups (config, baseline, plugin)
└── fixtures/             # Built-in fixtures
    └── builtin.py        # pyproject_data, all_py_files, etc.

Core Module Details

Error Handling (core/errors.py)

pycmdcheck uses a structured exception hierarchy:

from pycmdcheck.core.errors import (
    PycmdcheckError,      # Base exception (exit_code=1)
    ConfigError,          # Configuration errors (exit_code=2)
    PluginError,          # Plugin-related errors (exit_code=3)
    PluginConflictError,  # Two plugins define same check
    PluginLoadError,      # Failed to load plugin
    ProfileError,         # Profile-related errors (exit_code=2)
    CircularInheritanceError,  # Profile inheritance cycle
    CheckExecutionError,  # Error during check (exit_code=3)
)

Exit codes:

Code Meaning
0 Success (all checks passed)
1 Check failures
2 Configuration error
3 Internal/execution error

All exceptions support an optional hint parameter for user-friendly suggestions:

raise ConfigError(
    "Invalid profile 'strict'",
    hint="Did you mean 'recommended'? Available profiles: minimal, recommended, strict"
)

Command Execution (core/subprocess_helpers.py)

Centralized subprocess execution with consistent error handling:

from pycmdcheck.core.subprocess_helpers import run_command, CommandResult

# Run a command with timeout
result = run_command(
    ["ruff", "check", "."],
    timeout=60,
    cwd="/path/to/package",
    capture_output=True
)

if result.success:
    print(result.stdout)
else:
    print(f"Error: {result.stderr}")

The CommandResult dataclass provides:

  • returncode: Process exit code
  • stdout: Standard output (string)
  • stderr: Standard error (string)
  • success: Boolean (returncode == 0)
  • error: Optional error message

File I/O (core/file_helpers.py)

Utilities for reading files and finding Python files:

from pycmdcheck.core.file_helpers import (
    read_text,           # Read file with encoding handling
    parse_python_file,   # Parse Python file to AST
    parse_python_file_cached,  # Cached version
    find_python_files,   # Find all .py files
    is_test_path,        # Check if path is a test file
)

# Read a file (returns None on error)
content = read_text(Path("README.md"))

# Parse Python file
result = parse_python_file(Path("src/mypackage/module.py"))
if result.tree:
    # AST is available
    pass
elif result.error:
    print(f"Parse error: {result.error}")

# Find Python files (excludes __pycache__, .venv, etc.)
files = find_python_files(Path("src"))

AST Visitor Base Classes (core/ast_helpers.py)

Reusable base classes for AST visitors:

from pycmdcheck.core.ast_helpers import (
    BaseASTVisitor,          # Static utilities
    FileProcessingVisitor,   # Tracks file path and issues
    ClassTrackingVisitor,    # Tracks class context
    FunctionTrackingVisitor, # Tracks function context
    TypeCheckingAwareVisitor,# Handles TYPE_CHECKING blocks
)

Inheritance hierarchy:

BaseASTVisitor (static utilities)
    └── FileProcessingVisitor (file_path, locations)
            └── ClassTrackingVisitor (current_class, qualified_name)
                    └── FunctionTrackingVisitor (current_function)
                            └── TypeCheckingAwareVisitor (in_type_checking)

Example usage:

import ast
from pycmdcheck.core.ast_helpers import ClassTrackingVisitor

class DocstringVisitor(ClassTrackingVisitor):
    def __init__(self, file_path: str) -> None:
        super().__init__(file_path)
        self.missing_docstrings: list[str] = []

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        if self.is_public(node.name):
            docstring = self.get_docstring(node)
            if not docstring:
                full_name = self.qualified_name(node.name)
                self.add_location(node.lineno, full_name)
        self.generic_visit(node)

AST Caching (core/ast_cache.py)

Cache parsed AST modules to avoid repeated parsing:

from pycmdcheck.core.ast_cache import ASTCache, get_default_cache

# Use the default cache
cache = get_default_cache()
tree = cache.get_ast(Path("src/module.py"))

# Or create your own cache
cache = ASTCache()
tree = cache.get_ast(Path("src/module.py"))

# Check if file is cached
if Path("src/module.py") in cache:
    print("Already cached")

# Clear cache
cache.clear()

Progress Reporting (core/progress.py)

Rich-based progress reporting:

from pycmdcheck.core.progress import ProgressReporter

reporter = ProgressReporter(total=10, verbose=True)
reporter.start()

for check in checks:
    reporter.update(check.check_id, "running")
    # ... run check ...
    reporter.update(check.check_id, "passed")

reporter.finish(duration=1.5)

Result Builders (core/result_builders.py)

Fluent builders for constructing CheckResult objects with automatic location limiting:

from pycmdcheck.core.result_builders import (
    CheckResultBuilder,
    LocationCollector,
    collect_locations,
)

# LocationCollector - automatic limiting with truncation tracking
collector = LocationCollector(max_locations=10)
for file, line, name in all_issues:
    collector.add(file, line, symbol=name)

# collector.total_count = actual count (may exceed max)
# collector.is_truncated = True if limit hit
# collector.truncation_suffix() = " (showing 10 of 25)"

# CheckResultBuilder - fluent API with check metadata forwarding
result = CheckResultBuilder.for_check(MyCheck) \
    .failed(f"Found {collector.total_count} issues") \
    .with_locations(collector) \
    .with_hint("Fix the issues", show_truncation=True) \
    .build()

# Convenience method for common pattern
result = CheckResultBuilder.for_check(MyCheck) \
    .failed_with_locations(
        items=issues,
        message_template="Found {count} issue(s)",
        location_fn=lambda x: (x.file, x.line, x.name),
        hint="Fix the issues",
        max_locations=10,
    ) \
    .build()

Typed Check Configuration (core/check_config.py)

Typed dataclasses for check configuration with validation:

from pycmdcheck.core.check_config import (
    CheckConfig,
    CircularImportCheckConfig,
    CyclomaticComplexityConfig,
    get_config_class,
    parse_check_config,
)

# Parse config from pyproject.toml
config = parse_check_config("DP004", {
    "ignore_modules": ["mypackage.compat"],
    "max_cycles": 5
})

# Validate configuration
errors = config.validate()
if errors:
    for error in errors:
        print(f"Config error: {error}")

# Use in a check
cfg = CyclomaticComplexityConfig.from_dict(check_config or {})
max_complexity = cfg.max_complexity

Output Infrastructure

Base Formatter (output/base.py)

Abstract base class for all output formatters:

from pycmdcheck.output.base import BaseFormatter

class MyFormatter(BaseFormatter):
    def format(self, results: Results) -> str:
        # Implement formatting logic
        return "..."

Shared Constants (output/constants.py)

from pycmdcheck.output.constants import (
    STATUS_SYMBOLS,    # {"passed": "✓", "failed": "✗", ...}
    STATUS_EMOJIS,     # {"passed": "✅", "failed": "❌", ...}
    SEVERITY_LABELS,   # {"error": "ERROR", "warning": "WARNING", ...}
    SEVERITY_COLORS,   # {"error": "red", "warning": "yellow", ...}
    CSS_COLORS,        # {"error": "#dc3545", ...}
)

Markdown Utilities (output/markdown_utils.py)

from pycmdcheck.output.markdown_utils import (
    escape_markdown,    # Escape special characters
    escape_table_cell,  # Escape for table cells
    escape_html,        # Escape HTML entities
    wrap_in_code,       # Wrap in backticks
    make_link,          # Create [text](url)
    make_collapsible,   # Create <details> block
)

CLI Structure

The CLI is organized into commands and groups:

cli/
├── __init__.py          # Main entry point (main function)
├── utils.py             # Shared utilities
├── commands/
│   ├── check.py         # pycmdcheck [PATH]
│   ├── fix.py           # pycmdcheck fix
│   ├── list_cmd.py      # pycmdcheck list
│   ├── info.py          # pycmdcheck info
│   ├── explain.py       # pycmdcheck explain
│   ├── remote.py        # pycmdcheck remote
│   └── misc.py          # Other commands
└── groups/
    ├── config.py        # pycmdcheck config ...
    ├── baseline.py      # pycmdcheck baseline ...
    └── plugin.py        # pycmdcheck plugin ...

Check Module Structure

Large check modules are split into submodules for maintainability:

checks/code_quality/
├── __init__.py      # Re-exports all checks, contains remaining checks
├── ruff.py          # RuffCheck
├── mypy.py          # MypyCheck
├── docstrings.py    # DocstringCoverage, DoctestCheck
└── type_hints.py    # HasTypeHints

All checks remain importable from the top level:

# Both work:
from pycmdcheck.checks.code_quality import RuffCheck
from pycmdcheck.checks.code_quality.ruff import RuffCheck

Writing New Checks

See the plugin development guide for creating new checks.

Basic pattern:

from pycmdcheck.core import CheckProtocol, CheckResult, CheckStatus

class MyCheck:
    check_id = "XX001"
    title = "My Check"
    description = "Checks something important"
    rationale = "This is important because..."
    severity = "warning"
    group = "my-group"

    def check(self, root: Path, fixtures: dict) -> CheckResult:
        # Implementation
        if problem_found:
            return CheckResult(
                check_id=self.check_id,
                status=CheckStatus.FAILED,
                message="Problem description",
                hint="How to fix it",
            )
        return CheckResult(
            check_id=self.check_id,
            status=CheckStatus.PASSED,
        )