Завершение доработки команды rack_coverage
This commit is contained in:
@@ -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]]:
|
||||
|
||||
@@ -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))
|
||||
|
||||
0
net/xeaf/rack/tests/core/core_enum_values_tests.py
Normal file
0
net/xeaf/rack/tests/core/core_enum_values_tests.py
Normal file
Reference in New Issue
Block a user