Начальная версия rack_coverage

This commit is contained in:
2026-07-17 21:05:13 +03:00
parent aeebd28c52
commit 579d014dde
14 changed files with 659 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса для команды rack_coverage
"""
import ast
import configparser
import fnmatch
import importlib
import os
import re
from pathlib import Path
from django.conf import settings
from django.core.management import CommandError
from django.core.management.base import BaseCommand
from .coverage_structure_visitor import CoverageStructureVisitor
from .coverage_test_file_visitor import CoverageTestFileVisitor
class Command(BaseCommand):
"""
Проверяет соблюдение принципа один метод - один файл тестов
"""
# noinspection SpellCheckingInspection
CONFIG_FILE = '.rackcoveragerc'
""" Имя файла конфигурации """
CONFIG_SECTION = "rack-coverage"
""" Имя секции в файле конфигурации """
help = "Discover test coverage in the specified modules or the current directory."
""" Подсказка """
def add_arguments(self, parser):
"""
Позиционный аргумент: путь к пакету от корня
:param parser: Параметры командной строки
"""
parser.add_argument(
"package_path",
type=str,
nargs="?",
help="Module paths to check; can be modulename, modulename.TestCase or modulename.TestCase.test_method"
)
def handle(self, *args, **options):
# Конфигурация
config = self._load_config()
target_package = options["package_path"]
packages_to_check = [target_package] if target_package else config.get("packages", [])
exclude_patterns = config.get("exclude", [])
include_patterns = config.get("include", [])
if not packages_to_check:
raise CommandError("No package specified for verification.")
# Метрики и счётчики
success_count: int = 0
missing_files_count: int = 0
missing_classes_count: int = 0
skipped_packages_count: int = 0
for package_name in packages_to_check:
package_dir: Path | None = self._find_package_dir(package_name)
if not package_dir:
self.stdout.write(self.style.WARNING(f"⚠️ Пакет '{package_name}' пропущен: не найдена его директория"))
skipped_packages_count += 1
continue
# Смарт-автообнаружение зеркального пакета тестов вверх по иерархии
tests_info: tuple[str, Path] | None = self._autodiscover_tests_package(package_name)
if not tests_info:
self.stdout.write(self.style.ERROR(f"❌ Не удалось программно найти пакет тестов для: {package_name}"))
skipped_packages_count += 1
continue
tests_root_name, tests_root_dir = tests_info
self.stdout.write(
self.style.WARNING(
f"\n🔍 Анализ пакета [{package_name}] ➡️ Тесты обнаружены в [{tests_root_name}]..."
)
)
# Индексируем файлы тестов в автоматически найденном корне для быстрого поиска
test_files_map: dict[str, Path] = {}
for root, _, files in os.walk(tests_root_dir):
for file in files:
if file.endswith(".py") and file != "__init__.py":
test_files_map[file] = Path(root) / file
# Рекурсивно обходим файлы боевого кода
for root, _, files in os.walk(package_dir):
current_path: Path = Path(root)
# Защита от зацикливания, если папка tests сидит внутри пакета
if "tests" in current_path.parts:
continue
for file in files:
if not file.endswith(".py") or file == "__init__.py":
continue
source_file: Path = current_path / file
relative_to_package: Path = source_file.relative_to(package_dir)
module_parts: tuple[str, ...] = relative_to_package.with_suffix("").parts
module_dot_path: str = f"{package_name}.{'.'.join(module_parts)}"
extracted_items: list[tuple[str, str | None, str]] = self._extract_classes_and_methods(
source_file, module_dot_path
)
for method_name, class_name, full_path in extracted_items:
# Двухуровневая фильтрация: include / exclude
is_included: bool = any(fnmatch.fnmatch(full_path, p) for p in include_patterns)
if not is_included:
if any(fnmatch.fnmatch(full_path, p) for p in exclude_patterns):
continue
# 3. СТРОИМ ИМЯ ФАЙЛА КЛАССА С ТЕСТАМИ
if class_name:
if method_name == "__init__":
target_file_name: str = f"{self._camel_to_snake(class_name)}_tests.py"
else:
target_file_name = (
f"{self._camel_to_snake(class_name)}_{self._camel_to_snake(method_name)}_tests.py"
)
else:
target_file_name = f"{self._camel_to_snake(method_name)}_tests.py"
self.stdout.write(
self.style.ERROR(
f"Z Отсутствует файл тестов [{target_file_name}] для метода: {full_path}"
)
)
test_file_path: Path | None = test_files_map.get(target_file_name)
if not test_file_path:
self.stdout.write(
self.style.ERROR(
f"❌ Отсутствует файл тестов [{target_file_name}] для метода: {full_path}"
)
)
missing_files_count += 1
continue
# 4. СТРОИМ ОЖИДАЕМОЕ ИМЯ КЛАССА ТЕСТОВ
base_class: str = class_name if class_name else ""
if method_name == "__init__":
expected_class_name: str = f"{base_class}Tests"
else:
camel_method: str = "".join(
word.capitalize() for word in method_name.lstrip("_").split("_")
)
expected_class_name = f"{base_class}{camel_method}Tests"
# Парсим файл тестов и ищем в нём класс
try:
test_node: ast.Module = ast.parse(test_file_path.read_text(encoding="utf-8"))
test_visitor: CoverageTestFileVisitor = CoverageTestFileVisitor()
test_visitor.visit(test_node)
test_classes_in_file: set[str] = test_visitor.test_classes
except (SyntaxError, UnicodeDecodeError):
test_classes_in_file = set()
if expected_class_name not in test_classes_in_file:
self.stdout.write(
self.style.ERROR(
f"❌ В файле {test_file_path.relative_to(settings.BASE_DIR)} "
f"отсутствует класс [{expected_class_name}] для метода: {full_path}"
)
)
missing_classes_count += 1
else:
success_count += 1
# Вывод финального промышленного отчёта в терминал
total_errors: int = missing_files_count + missing_classes_count
self.stdout.write("\n" + "=" * 60)
self.stdout.write(self.style.SUCCESS(f"📊 ИТОГОВЫЙ ОТЧЁТ ПОКРЫТИЯ МЕТОДОВ:"))
self.stdout.write(f" ✅ Успешно проверено и покрыто методов: {success_count}")
if skipped_packages_count > 0:
self.stdout.write(self.style.WARNING(f" ⚠️ Пропущено / не найдено пакетов: {skipped_packages_count}"))
if missing_files_count > 0:
self.stdout.write(self.style.ERROR(f" ❌ Отсутствует файлов тестов: {missing_files_count}"))
if missing_classes_count > 0:
self.stdout.write(self.style.ERROR(f" ❌ Отсутствует классов внутри файлов тестов: {missing_classes_count}"))
self.stdout.write("=" * 60 + "\n")
if total_errors > 0:
raise CommandError(f"🛑 Проверка провалена. Суммарно пропущено проверок: {total_errors}")
@classmethod
def _extract_classes_and_methods(cls, file_path: Path, module_dot_path: str) -> list[tuple[str, str | None, str]]:
"""
Разбирает файл через AST, используя CoverageStructureVisitor
:param file_path: Путь к файлу
:param module_dot_path: Путь к модулю
:return: Список кортежей с именами классов, методов и именами файлов
"""
try:
node = ast.parse(file_path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
return []
visitor = CoverageStructureVisitor(module_dot_path)
visitor.visit(node)
return visitor.results
@classmethod
def _extract_test_classes(cls, test_file_path: Path) -> set[str]:
"""
Парсит файл тестов и возвращает имена всех объявленных в нем классов
:param test_file_path: Путь к файлу с тестами
:return: Множество имен классов
"""
try:
node = ast.parse(test_file_path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
return set()
visitor = CoverageTestFileVisitor()
visitor.visit(node)
return visitor.test_classes
def _autodiscover_tests_package(self, package_name: str) -> tuple[str, Path] | None:
"""
Умный алгоритм автоматического обнаружения тестовых пакетов
:param package_name: Имя пакета
:return: Кортеж из имени пакета тестов и пути к нему, или None, если не найден
"""
parts = package_name.split(".")
# Проходим циклом с конца имени пакета вверх до корня
for i in range(len(parts), 0, -1):
base_parts = parts[:i]
suffix_parts = parts[i:]
# Собираем гипотетическое имя пакета тестов
candidate_parts = base_parts + ["tests"] + suffix_parts
candidate_name = ".".join(candidate_parts)
# Проверяем, существует ли такой пакет физически на диске
candidate_dir = self._find_package_dir(candidate_name)
if candidate_dir:
return candidate_name, candidate_dir
return None
@classmethod
def _camel_to_snake(cls, name: str) -> str:
"""
Переводит CamelCase в snake_case
:param name: Имя в CamelCase
:return: Имя в snake_case
"""
clean_name = name.lstrip("_")
clean_name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", clean_name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", clean_name).lower()
@classmethod
def _snake_to_camel(cls, name: str) -> str:
"""
Переводит snake_case в CamelCase
:param name: Имя в snake_case
:return: Имя в CamelCase
"""
clean_name = name.lstrip("_")
return "".join(word.capitalize() for word in clean_name.split("_"))
@classmethod
def _find_package_dir(cls, package_name: str) -> Path | None:
"""
Возвращает директорию пакета
:param package_name: Имя пакета
:return: Путь к директории пакета или None, если пакет не найден
"""
try:
module = importlib.import_module(package_name)
if hasattr(module, "__path__"):
paths = list(module.__path__)
if paths:
return Path(paths[0])
except (ModuleNotFoundError, ImportError, ValueError):
pass
return None
def _load_config(self) -> dict:
"""
Загружает файл конфигурации
:return: Словарь с настройками
"""
rc_path = Path(settings.BASE_DIR) / self.CONFIG_FILE
if not rc_path.exists():
return {}
config = configparser.ConfigParser()
try:
config.read(rc_path, encoding="utf-8")
if not config.has_section("rack-coverage"):
return {}
raw_packages = config.get("rack-coverage", "packages", fallback="")
raw_exclude = config.get("rack-coverage", "exclude", fallback="")
raw_include = config.get("rack-coverage", "include", fallback="")
# Чистим строки от пробелов и переносов, превращая в списки
packages_list = [p.strip() for p in raw_packages.splitlines() if p.strip()]
exclude_list = [e.strip() for e in raw_exclude.splitlines() if e.strip()]
include_list = [i.strip() for i in raw_include.splitlines() if i.strip()]
return {
"packages": packages_list,
"exclude": exclude_list,
"include": include_list
}
except configparser.Error as err:
self.stdout.write(self.style.ERROR(f"Error reading configuration file {self.CONFIG_FILE}: {err}"))
return {}