353 lines
15 KiB
Python
353 lines
15 KiB
Python
# 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 {}
|