Начальная версия rack_coverage
This commit is contained in:
352
net/xeaf/rack/management/commands/rack_coverage.py
Normal file
352
net/xeaf/rack/management/commands/rack_coverage.py
Normal 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 {}
|
||||
Reference in New Issue
Block a user