406 lines
16 KiB
Python
406 lines
16 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
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from django.core.management import CommandError
|
|
|
|
from net.xeaf.rack.core import CoreCommand
|
|
from .coverage_structure_visitor import CoverageStructureVisitor
|
|
from .coverage_test_file_visitor import CoverageTestFileVisitor
|
|
|
|
|
|
class Command(CoreCommand):
|
|
"""
|
|
Проверяет соблюдение принципа один метод - один файл тестов
|
|
"""
|
|
|
|
# noinspection SpellCheckingInspection
|
|
CONFIG_FILE = '.rackcoveragerc'
|
|
""" Имя файла конфигурации """
|
|
|
|
CONFIG_SECTION = "rack-coverage"
|
|
""" Имя секции в файле конфигурации """
|
|
|
|
help = "Discover test coverage in the specified modules or the current directory."
|
|
""" Подсказка """
|
|
|
|
success_count: int = 0
|
|
""" Счетчик успешно проверенных методов """
|
|
|
|
missing_files_count: int = 0
|
|
""" Счетчик не найденных файлов с тестами """
|
|
|
|
missing_classes_count: int = 0
|
|
""" Счетчик не найденных классов с тестами """
|
|
|
|
skipped_packages_count: int = 0
|
|
""" Счетчик пропущенных пакетов """
|
|
|
|
test_files_map: dict[str, Path] = {}
|
|
""" Карта файлов тестов """
|
|
|
|
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.")
|
|
|
|
|
|
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"[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"[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._index_test_files(tests_root_dir)
|
|
|
|
# Обходим файлы боевого кода
|
|
for root, _, files in os.walk(package_dir):
|
|
current_path: Path = Path(root)
|
|
|
|
# Защита от зацикливания
|
|
if "tests" in current_path.parts:
|
|
continue
|
|
|
|
for file in files:
|
|
|
|
# Только файлы python и не файлы пакетов
|
|
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
|
|
|
|
# Получаем путь к файлу с тестами
|
|
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"[E] Method: {full_path}"))
|
|
self.stdout.write(self.style.ERROR(f" Test file {target_file_name} is missing."))
|
|
self.missing_files_count += 1
|
|
continue
|
|
|
|
expected_class_name = self._get_expected_class_name(class_name, method_name)
|
|
self._parse_tests_file(test_file_path, expected_class_name, full_path)
|
|
|
|
# Вывод финального отчёта
|
|
self._final_report()
|
|
|
|
def _final_report(self) -> None:
|
|
"""
|
|
Выводит финальный отчёт в терминал
|
|
"""
|
|
|
|
total_errors: int = self.missing_files_count + self.missing_classes_count
|
|
|
|
self._report_separator(first_new_line=True)
|
|
self.stdout.write(self.style.SUCCESS(f"Test coverage report:"))
|
|
self._report_separator(double=False)
|
|
self._report_int_value("Success", self.success_count)
|
|
if self.skipped_packages_count > 0:
|
|
self._report_int_value("Skipped packages", self.skipped_packages_count)
|
|
if self.missing_files_count > 0:
|
|
self._report_int_value("Missing test files", self.missing_files_count)
|
|
if self.missing_classes_count > 0:
|
|
self._report_int_value("Missing test classes", self.missing_classes_count)
|
|
self._report_separator(double=False, last_new_line=False)
|
|
|
|
if total_errors > 0:
|
|
self._report_int_value("FAIL! 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]]:
|
|
"""
|
|
Разбирает файл через 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 {}
|