Завершение доработки команды 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 fnmatch
import importlib
import sys
import os
import re
import sys
from pathlib import Path
from django.conf import settings
@@ -52,6 +52,9 @@ class Command(CoreCommand):
skipped_packages_count: int = 0
""" Счетчик пропущенных пакетов """
test_files_map: dict[str, Path] = {}
""" Карта файлов тестов """
def add_arguments(self, parser):
"""
Позиционный аргумент: путь к пакету от корня
@@ -67,6 +70,9 @@ class Command(CoreCommand):
)
def handle(self, *args, **options):
"""
Обработка команды построения покрытия тестами
"""
# Конфигурация
config = self._load_config()
@@ -79,45 +85,35 @@ class Command(CoreCommand):
if not packages_to_check:
raise CommandError("No package specified for verification.")
# Метрики и счётчики
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}' пропущен: не найдена его директория"))
self.stdout.write(self.style.WARNING(f"[F] Package '{package_name}' skipped: its directory was not found."))
self.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}"))
self.stdout.write(self.style.ERROR(f"[F] Could not find a test package for: {package_name}."))
self.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}]..."
)
)
self._index_test_files(tests_root_dir)
# Индексируем файлы тестов в автоматически найденном корне для быстрого поиска
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:
# Только файлы python и не файлы пакетов
if not file.endswith(".py") or file == "__init__.py":
continue
@@ -131,73 +127,28 @@ class Command(CoreCommand):
)
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)
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)
# Получаем путь к файлу с тестами
test_file_path, target_file_name = self._get_test_file_path(class_name, method_name)
if not test_file_path:
self.stdout.write(
self.style.ERROR(
f"❌ Отсутствует файл тестов [{target_file_name}] для метода: {full_path}"
)
)
self.stdout.write(self.style.ERROR(f"[E] Method: {full_path}"))
self.stdout.write(self.style.ERROR(f" Test file {target_file_name} is missing."))
self.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"
expected_class_name = self._get_expected_class_name(class_name, method_name)
self._parse_tests_file(test_file_path, expected_class_name, 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"❌ В файле {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()
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)
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)
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
def _extract_classes_and_methods(cls, file_path: Path, module_dot_path: str) -> list[tuple[str, str | None, str]]: