Начальная версия rack_coverage

This commit is contained in:
2026-07-17 21:05:13 +03:00
parent aeebd28c52
commit 579d014dde
14 changed files with 659 additions and 0 deletions

18
.rackcoveragerc Normal file
View File

@@ -0,0 +1,18 @@
[rack-coverage]
# Пакеты для сканирования
packages =
net.xeaf.rack
net.xeaf.rack.foo
# То, что мы выбрасываем (маски исключения)
exclude =
*.__*
*.get_absolute_url
net.xeaf.rack.core.tests
net.xeaf.rack.core.views.experimental.*
# То, что мы ПРИНУДИТЕЛЬНО возвращаем назад (маски включения)
# Этот список имеет наивысший приоритет!
include =
*.__init__
*.__call__

View File

@@ -0,0 +1,75 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoverageStructureVisitor
"""
import ast
class CoverageStructureVisitor(ast.NodeVisitor):
"""
Обходчик синтаксического дерева кода проекта
"""
def __init__(self, module_dot_path: str):
"""
Инициализация
:param module_dot_path: Путь к модулю
"""
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
class_name = self.class_stack[-1] if self.class_stack else None
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, class_name, full_path))

View File

@@ -0,0 +1,33 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoverageFileVisitor
"""
import ast
class CoverageTestFileVisitor(ast.NodeVisitor):
"""
Обходчик синтаксического дерева файла тестов
"""
def __init__(self):
"""
Инициализация
"""
super().__init__()
self.test_classes = set()
def visit_ClassDef(self, class_node) -> None:
"""
Обходчик классов
"""
self.test_classes.add(class_node.name)
self.generic_visit(class_node)

View File

@@ -0,0 +1,352 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса для команды rack_coverage
"""
import ast
import configparser
import fnmatch
import importlib
import os
import re
from pathlib import Path
from django.conf import settings
from django.core.management import CommandError
from django.core.management.base import BaseCommand
from .coverage_structure_visitor import CoverageStructureVisitor
from .coverage_test_file_visitor import CoverageTestFileVisitor
class Command(BaseCommand):
"""
Проверяет соблюдение принципа один метод - один файл тестов
"""
# noinspection SpellCheckingInspection
CONFIG_FILE = '.rackcoveragerc'
""" Имя файла конфигурации """
CONFIG_SECTION = "rack-coverage"
""" Имя секции в файле конфигурации """
help = "Discover test coverage in the specified modules or the current directory."
""" Подсказка """
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.")
# Метрики и счётчики
success_count: int = 0
missing_files_count: int = 0
missing_classes_count: int = 0
skipped_packages_count: int = 0
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}' пропущен: не найдена его директория"))
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}"))
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}]..."
)
)
# Индексируем файлы тестов в автоматически найденном корне для быстрого поиска
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:
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
# 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)
if not test_file_path:
self.stdout.write(
self.style.ERROR(
f"❌ Отсутствует файл тестов [{target_file_name}] для метода: {full_path}"
)
)
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"
# Парсим файл тестов и ищем в нём класс
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}"
)
)
missing_classes_count += 1
else:
success_count += 1
# Вывод финального промышленного отчёта в терминал
total_errors: int = missing_files_count + missing_classes_count
self.stdout.write("\n" + "=" * 60)
self.stdout.write(self.style.SUCCESS(f"📊 ИТОГОВЫЙ ОТЧЁТ ПОКРЫТИЯ МЕТОДОВ:"))
self.stdout.write(f" ✅ Успешно проверено и покрыто методов: {success_count}")
if skipped_packages_count > 0:
self.stdout.write(self.style.WARNING(f" ⚠️ Пропущено / не найдено пакетов: {skipped_packages_count}"))
if missing_files_count > 0:
self.stdout.write(self.style.ERROR(f" ❌ Отсутствует файлов тестов: {missing_files_count}"))
if missing_classes_count > 0:
self.stdout.write(self.style.ERROR(f" ❌ Отсутствует классов внутри файлов тестов: {missing_classes_count}"))
self.stdout.write("=" * 60 + "\n")
if total_errors > 0:
raise CommandError(f"🛑 Проверка провалена. Суммарно пропущено проверок: {total_errors}")
@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 {}

View File

@@ -0,0 +1,80 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса для команды rack_coverage
"""
import ast
import configparser
from pathlib import Path
from django.conf import settings
from django.core.management.base import no_translations
from net.xeaf.rack.core import CoreCommand
class Command(CoreCommand):
"""
Осуществляет проверку покрытия тестами
"""
# noinspection SpellCheckingInspection
CONFIG_FILE = '.rackcoveragerc'
""" Имя файла конфигурации """
CONFIG_SECTION = "rack-coverage"
""" Имя секции в файле конфигурации """
@no_translations
def handle(self, *args, **options):
"""
Проверяет соблюдение принципа один метод - один файл тестов
:params args: Позиционные аргументы
:params options: Дополнительные параметры
"""
config = self._load_config()
self.stdout.write(self.style.HTTP_INFO(f"Loading configuration file: {config}"))
pass
def _load_config(self) -> dict[str, list[str]]:
"""
Читает файл конфигурации
: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(self.CONFIG_SECTION):
return {}
# Парсим строки, очищая их от пробелов и переносов
raw_apps = config.get(self.CONFIG_SECTION, "apps", fallback="")
raw_ignore = config.get(self.CONFIG_SECTION, "ignore", fallback="")
apps_list = [a.strip() for a in raw_apps.splitlines() if a.strip()]
ignore_list = [i.strip() for i in raw_ignore.splitlines() if i.strip()]
return {
"apps": apps_list,
"ignore": ignore_list
}
except configparser.Error as err:
self.stdout.write(self.style.ERROR(f"Error reading configuration file {self.CONFIG_FILE}: {err}"))
return {}

View File

@@ -0,0 +1,75 @@
# 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))

View File

@@ -0,0 +1,12 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания тестов классов ядра
"""
from .core_enum_choices_tests import CoreEnumChoicesTests
from .core_enum_keys_tests import CoreEnumKeysTests

View File

@@ -0,0 +1,7 @@
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreEnumChoicesTests(CoreSimpleTestCase):
def test_enum_choices(self):
self.assertEqual(1, 2)

View File

@@ -0,0 +1,7 @@
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreEnumKeysTests(CoreSimpleTestCase):
def test_enum_keys(self):
self.assertEqual(1, 2)