Завершение доработки команды rack_coverage
This commit is contained in:
@@ -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]]:
|
||||||
|
|||||||
@@ -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