Завершение доработки команды rack_coverage

This commit is contained in:
2026-07-17 23:18:02 +03:00
parent 11b332be2d
commit 3324dbe9fb
3 changed files with 109 additions and 150 deletions

View File

@@ -12,9 +12,9 @@ import ast
import configparser import configparser
import fnmatch import fnmatch
import importlib import importlib
import sys
import os import os
import re import re
import sys
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
@@ -52,6 +52,9 @@ class Command(CoreCommand):
skipped_packages_count: int = 0 skipped_packages_count: int = 0
""" Счетчик пропущенных пакетов """ """ Счетчик пропущенных пакетов """
test_files_map: dict[str, Path] = {}
""" Карта файлов тестов """
def add_arguments(self, parser): def add_arguments(self, parser):
""" """
Позиционный аргумент: путь к пакету от корня Позиционный аргумент: путь к пакету от корня
@@ -67,6 +70,9 @@ class Command(CoreCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
"""
Обработка команды построения покрытия тестами
"""
# Конфигурация # Конфигурация
config = self._load_config() config = self._load_config()
@@ -79,45 +85,35 @@ class Command(CoreCommand):
if not packages_to_check: if not packages_to_check:
raise CommandError("No package specified for verification.") raise CommandError("No package specified for verification.")
# Метрики и счётчики
for package_name in packages_to_check: for package_name in packages_to_check:
package_dir: Path | None = self._find_package_dir(package_name) package_dir: Path | None = self._find_package_dir(package_name)
if not package_dir: if not package_dir:
self.stdout.write(self.style.WARNING(f"⚠️ Пакет '{package_name}' пропущен: не найдена его директория")) self.stdout.write(self.style.WARNING(f"[F] Package '{package_name}' skipped: its directory was not found."))
self.skipped_packages_count += 1 self.skipped_packages_count += 1
continue continue
# Смарт-автообнаружение зеркального пакета тестов вверх по иерархии
tests_info: tuple[str, Path] | None = self._autodiscover_tests_package(package_name) tests_info: tuple[str, Path] | None = self._autodiscover_tests_package(package_name)
if not tests_info: if not tests_info:
self.stdout.write(self.style.ERROR(f"❌ Не удалось программно найти пакет тестов для: {package_name}")) self.stdout.write(self.style.ERROR(f"[F] Could not find a test package for: {package_name}."))
self.skipped_packages_count += 1 self.skipped_packages_count += 1
continue continue
tests_root_name, tests_root_dir = tests_info tests_root_name, tests_root_dir = tests_info
self.stdout.write( self._index_test_files(tests_root_dir)
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): for root, _, files in os.walk(package_dir):
current_path: Path = Path(root) current_path: Path = Path(root)
# Защита от зацикливания, если папка tests сидит внутри пакета # Защита от зацикливания
if "tests" in current_path.parts: if "tests" in current_path.parts:
continue continue
for file in files: for file in files:
# Только файлы python и не файлы пакетов
if not file.endswith(".py") or file == "__init__.py": if not file.endswith(".py") or file == "__init__.py":
continue continue
@@ -131,73 +127,28 @@ class Command(CoreCommand):
) )
for method_name, class_name, full_path in extracted_items: for method_name, class_name, full_path in extracted_items:
# Двухуровневая фильтрация: include / exclude
# Фильтрация: include / exclude
is_included: bool = any(fnmatch.fnmatch(full_path, p) for p in include_patterns) is_included: bool = any(fnmatch.fnmatch(full_path, p) for p in include_patterns)
if not is_included: if not is_included:
if any(fnmatch.fnmatch(full_path, p) for p in exclude_patterns): if any(fnmatch.fnmatch(full_path, p) for p in exclude_patterns):
continue continue
# 3. СТРОИМ ИМЯ ФАЙЛА КЛАССА С ТЕСТАМИ # Получаем путь к файлу с тестами
if class_name: test_file_path, target_file_name = self._get_test_file_path(class_name, method_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: if not test_file_path:
self.stdout.write( self.stdout.write(self.style.ERROR(f"[E] Method: {full_path}"))
self.style.ERROR( self.stdout.write(self.style.ERROR(f" Test file {target_file_name} is missing."))
f"❌ Отсутствует файл тестов [{target_file_name}] для метода: {full_path}"
)
)
self.missing_files_count += 1 self.missing_files_count += 1
continue continue
# 4. СТРОИМ ОЖИДАЕМОЕ ИМЯ КЛАССА ТЕСТОВ expected_class_name = self._get_expected_class_name(class_name, method_name)
base_class: str = class_name if class_name else "" self._parse_tests_file(test_file_path, expected_class_name, full_path)
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}"
)
)
self.missing_classes_count += 1
else:
self.success_count += 1
# Вывод финального промышленного отчёта в терминал
self._final_report() self._final_report()
def _final_report(self): def _final_report(self) -> None:
""" """
Выводит финальный отчёт в терминал Выводит финальный отчёт в терминал
""" """
@@ -217,9 +168,92 @@ class Command(CoreCommand):
self._report_separator(double=False, last_new_line=False) self._report_separator(double=False, last_new_line=False)
if total_errors > 0: if total_errors > 0:
self._report_int_value("Test coverage failed, total number of errors", total_errors) self._report_int_value("FAILE! Total number of errors", total_errors)
self._report_separator(double=False, last_new_line=False) self._report_separator(double=False, last_new_line=False)
sys.exit(1) sys.exit(1)
else:
self._report_int_value("SUCCESS! Total number of items", self.success_count)
self._report_separator(double=False, last_new_line=False)
sys.exit(0)
def _get_test_file_path(self, class_name: str | None, method_name: str) -> tuple[Path | None, str]:
"""
Возвращает путь к тестовому файлу для указанного класса и метода.
:param class_name: Имя класса
:param method_name: Имя метода
:return: Путь к тестовому файлу и имя файла
"""
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"
return self.test_files_map.get(target_file_name), target_file_name
@classmethod
def _get_expected_class_name(cls, class_name: str | None, method_name: str) -> str:
"""
Получает ожидаемое имя класса
:param class_name: Имя класса
:param method_name: Имя метода
"""
base_class: str = class_name if class_name else ""
if method_name == "__init__":
return f"{base_class}Tests"
else:
camel_method: str = "".join(
word.capitalize() for word in method_name.lstrip("_").split("_")
)
return f"{base_class}{camel_method}Tests"
def _parse_tests_file(self, test_file_path: Path, expected_class_name: str, full_path: str) -> None:
"""
Парсит файлы тестов
:param test_file_path: Путь к файлу тестов
:param expected_class_name: Ожидаемое имя класса
:param full_path: Полный путь к методу
"""
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"[E] The file {test_file_path.relative_to(settings.BASE_DIR)}"))
self.stdout.write(self.style.ERROR(f" is missing the class {expected_class_name}"))
self.stdout.write(self.style.ERROR(f" for the method {full_path}."))
self.missing_classes_count += 1
else:
self.success_count += 1
@classmethod
def _index_test_files(cls, tests_root_dir: Path) -> None:
"""
Индексирует файлы тестов в автоматически найденном корне для быстрого поиска
:param tests_root_dir: Корневая директория для поиска файлов тестов
"""
cls.test_files_map = {}
for root, _, files in os.walk(tests_root_dir):
for file in files:
if file.endswith(".py") and file != "__init__.py":
cls.test_files_map[file] = Path(root) / file
@classmethod @classmethod
def _extract_classes_and_methods(cls, file_path: Path, module_dot_path: str) -> list[tuple[str, str | None, str]]: def _extract_classes_and_methods(cls, file_path: Path, module_dot_path: str) -> list[tuple[str, str | None, str]]:

View File

@@ -1,75 +0,0 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса RackCoverageVisitor
"""
import ast
import configparser
from pathlib import Path
from django.conf import settings
class RackCoverageVisitor(ast.NodeVisitor):
"""
Реализует методы для рекурсивного обхода дерева элементов
"""
def __init__(self, module_dot_path: str):
"""
Инициализация
"""
super().__init__()
self.module_dot_path = module_dot_path
self.class_stack = []
self.results = []
def visit_ClassDef(self, class_node) -> None:
"""
Обработка класса
:param class_node: Узел класса
"""
self.class_stack.append(class_node.name)
self.generic_visit(class_node)
self.class_stack.pop()
def visit_FunctionDef(self, func_node) -> None:
"""
Обработка функции
:param func_node: Узел функции
"""
self._handle_function(func_node)
def visit_AsyncFunctionDef(self, async_func_node) -> None:
"""
Обработка асинхронной функции
:param async_func_node: Узел асинхронной функции
"""
self._handle_function(async_func_node)
def _handle_function(self, func_node) -> None:
"""
Общий метод для обработки функций
:param func_node: Узел функции
"""
func_name = func_node.name
if self.class_stack:
class_chain = ".".join(self.class_stack)
full_path = f"{self.module_dot_path}.{class_chain}.{func_name}"
else:
full_path = f"{self.module_dot_path}.{func_name}"
self.results.append((func_name, full_path))