Public Access
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 27s
Build Images / detect-type (push) Successful in 42s
Post-merge / badges (push) Successful in 30s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 1m44s
240 lines
8.6 KiB
Python
240 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Pre-commit / CI check: ensure every changed or new file has corresponding tests.
|
|
|
|
Configuration (``[tool.devx.check_test_coverage]`` in pyproject.toml):
|
|
|
|
``rules`` — list of mapping rules, each with:
|
|
|
|
``source_pattern`` — glob pattern for source files (e.g. ``"scripts/*.py"``)
|
|
``test_paths`` — list of test path templates (e.g. ``["scripts/tests/test_{name}", "tests/unit/test_{name}"]``)
|
|
``description`` — human-readable description for error messages
|
|
|
|
``skip_patterns`` — list of file patterns to skip (e.g. ``["__init__.py", "config.py"]``)
|
|
``test_file_indicators`` — substrings that identify a file as a test (default: ``["tests/", "/test_", "_test.py"]``)
|
|
``skip_extensions`` — file extensions to skip (default: .md, .yml, .yaml, .json, .tf, .sh, .conf, .service)
|
|
|
|
Built-in defaults cover common Python project layouts (``scripts/*.py``, ``src/**/*.py``).
|
|
Project-specific rules are merged with defaults (first match wins).
|
|
|
|
Usage::
|
|
|
|
python3 -m devx.tools.check_test_coverage [--staged-only] [--warn-only]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import subprocess # nosec B404
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from devx.config import _load_pyproject_devx
|
|
from devx.i18n import _
|
|
|
|
DEFAULT_TEST_INDICATORS = ["tests/", "/test_", "_test.py"]
|
|
DEFAULT_SKIP_EXTENSIONS = (".md", ".yml", ".yaml", ".json", ".tf", ".sh", ".conf", ".service")
|
|
|
|
# Built-in rules for common Python project layouts
|
|
BUILTIN_RULES: list[dict[str, object]] = [
|
|
{
|
|
"source_pattern": "scripts/*.py",
|
|
"test_paths": ["scripts/tests/test_{name}", "tests/unit/test_{name}"],
|
|
"description": "Missing unit test: scripts/tests/test_{name} or tests/unit/test_{name}",
|
|
},
|
|
{
|
|
"source_pattern": "src/**/*.py",
|
|
"test_paths": ["tests/unit/test_{name}", "tests/unit/test_{module}_{name}"],
|
|
"description": "Missing unit test: tests/unit/test_{name}",
|
|
},
|
|
]
|
|
|
|
|
|
def _load_rules() -> tuple[list[dict[str, object]], list[str], list[str], tuple[str, ...]]:
|
|
"""Load test coverage rules from pyproject.toml."""
|
|
devx_cfg = _load_pyproject_devx()
|
|
cfg_raw = devx_cfg.get("check_test_coverage", {})
|
|
if not isinstance(cfg_raw, dict):
|
|
return BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS
|
|
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
|
|
|
rules_raw = cfg.get("rules", BUILTIN_RULES)
|
|
rules: list[dict[str, object]] = [dict(r) for r in rules_raw] if isinstance(rules_raw, list) else BUILTIN_RULES
|
|
|
|
skip_raw = cfg.get("skip_patterns", [])
|
|
skip_patterns: list[str] = [str(s) for s in skip_raw] if isinstance(skip_raw, list) else []
|
|
|
|
indicators_raw = cfg.get("test_file_indicators", DEFAULT_TEST_INDICATORS)
|
|
indicators: list[str] = (
|
|
[str(s) for s in indicators_raw] if isinstance(indicators_raw, list) else DEFAULT_TEST_INDICATORS
|
|
)
|
|
|
|
skip_ext_raw = cfg.get("skip_extensions", list(DEFAULT_SKIP_EXTENSIONS))
|
|
if isinstance(skip_ext_raw, list):
|
|
skip_ext: tuple[str, ...] = tuple(str(s) for s in skip_ext_raw)
|
|
else:
|
|
skip_ext = DEFAULT_SKIP_EXTENSIONS
|
|
|
|
return rules, skip_patterns, indicators, skip_ext
|
|
|
|
|
|
def _changed_files(staged_only: bool, repo_root: Path) -> list[str]:
|
|
"""Return list of changed file paths relative to repo root."""
|
|
if staged_only:
|
|
cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"]
|
|
else:
|
|
# Compare against origin/master for CI usage
|
|
cmd = ["git", "diff", "origin/master...HEAD", "--name-only", "--diff-filter=ACMR"]
|
|
result = subprocess.run( # nosec B603, B607
|
|
cmd, capture_output=True, text=True, check=False, cwd=repo_root
|
|
)
|
|
if result.returncode != 0:
|
|
# fallback: just use staged files
|
|
result = subprocess.run( # nosec B603, B607
|
|
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=repo_root,
|
|
)
|
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def _is_test_file(filepath: str, indicators: list[str]) -> bool:
|
|
"""Check if a file is a test file."""
|
|
return any(indicator in filepath for indicator in indicators)
|
|
|
|
|
|
def _should_skip_file(
|
|
filepath: str,
|
|
skip_patterns: list[str],
|
|
skip_extensions: tuple[str, ...],
|
|
) -> bool:
|
|
"""Check if a file should be skipped."""
|
|
if filepath.startswith("."):
|
|
return True
|
|
if filepath.endswith(skip_extensions):
|
|
return True
|
|
name = Path(filepath).name
|
|
return any(fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(filepath, pattern) for pattern in skip_patterns)
|
|
|
|
|
|
def _resolve_test_path(template: str, source_path: str, repo_root: Path) -> Path:
|
|
"""Resolve a test path template to an actual path.
|
|
|
|
Templates can use:
|
|
- ``{name}`` — the source file's name (without extension)
|
|
- ``{module}`` — the source file's parent directory name
|
|
- ``{package_prefix}`` — underscore-joined subdirectories (for nested modules)
|
|
"""
|
|
path = Path(source_path)
|
|
name = path.stem
|
|
module = path.parent.name
|
|
|
|
# Build package prefix for nested modules (e.g. scripts/utils/secrets.py -> utils)
|
|
parts = path.parts
|
|
package_prefix = ""
|
|
if len(parts) > 2:
|
|
package_prefix = "_".join(parts[1:-1])
|
|
|
|
resolved = template.format(
|
|
name=name,
|
|
module=module,
|
|
package_prefix=package_prefix,
|
|
)
|
|
# Normalize hyphens to underscores (Python module naming)
|
|
resolved = resolved.replace("-", "_")
|
|
return repo_root / resolved
|
|
|
|
|
|
def _find_missing_tests(
|
|
files: list[str],
|
|
repo_root: Path,
|
|
rules: list[dict[str, object]],
|
|
skip_patterns: list[str],
|
|
test_indicators: list[str],
|
|
skip_extensions: tuple[str, ...],
|
|
) -> dict[str, str]:
|
|
"""Map each untested file to the reason it's untested."""
|
|
missing: dict[str, str] = {}
|
|
|
|
for f in files:
|
|
# Skip test files themselves
|
|
if _is_test_file(f, test_indicators):
|
|
continue
|
|
|
|
# Skip config, docs, meta files
|
|
if _should_skip_file(f, skip_patterns, skip_extensions):
|
|
continue
|
|
|
|
for rule in rules:
|
|
pattern = str(rule.get("source_pattern", ""))
|
|
if not fnmatch.fnmatch(f, pattern):
|
|
continue
|
|
|
|
test_templates = rule.get("test_paths", [])
|
|
if not isinstance(test_templates, list):
|
|
continue
|
|
|
|
description_template = str(rule.get("description", "Missing test for {f}"))
|
|
|
|
test_paths = [_resolve_test_path(str(t), f, repo_root) for t in test_templates]
|
|
|
|
# Check if any test path exists (with .py extension)
|
|
found = False
|
|
for tp in test_paths:
|
|
if tp.with_suffix(".py").exists() or tp.exists():
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
# Format description with file info
|
|
name = Path(f).stem
|
|
missing[f] = description_template.format(
|
|
name=name,
|
|
f=f,
|
|
test_name=f"test_{name}".replace("-", "_"),
|
|
)
|
|
break
|
|
|
|
# If no rule matched, the file is not checked (no test requirement)
|
|
# This is intentional — only files matching a rule need tests
|
|
|
|
return missing
|
|
|
|
|
|
@click.command()
|
|
@click.option("--staged-only", is_flag=True, help=_("Only check staged files (for pre-commit)"))
|
|
@click.option("--warn-only", is_flag=True, help=_("Print warnings but always exit 0"))
|
|
def cli(staged_only: bool, warn_only: bool) -> None:
|
|
"""Check that changed files have corresponding tests."""
|
|
repo_root = Path.cwd()
|
|
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
|
|
|
files = _changed_files(staged_only, repo_root)
|
|
if not files:
|
|
click.echo(_("[check_test_coverage] No changed files to check."))
|
|
return
|
|
|
|
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
|
if not missing:
|
|
click.echo(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
|
return
|
|
|
|
click.echo("[check_test_coverage] FAILED: missing tests for changed files:\n", err=True)
|
|
for f, reason in missing.items():
|
|
click.echo(f" {f}", err=True)
|
|
click.echo(f" -> {reason}", err=True)
|
|
|
|
click.echo(
|
|
_("\n[check_test_coverage] Fix: add the missing test file(s) before committing."),
|
|
err=True,
|
|
)
|
|
|
|
if not warn_only:
|
|
raise click.ClickException(_("Missing tests for changed files."))
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
cli() # pragma: no cover
|