From 3324dbe9fbfbff06586d56f9be4723e1ca775c4f Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Fri, 17 Jul 2026 23:18:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20rac?= =?UTF-8?q?k=5Fcoverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rack/management/commands/rack_coverage.py | 184 +++++++++++------- .../commands/rack_coverage_visitor.py | 75 ------- .../rack/tests/core/core_enum_values_tests.py | 0 3 files changed, 109 insertions(+), 150 deletions(-) delete mode 100644 net/xeaf/rack/management/commands/rack_coverage_visitor.py create mode 100644 net/xeaf/rack/tests/core/core_enum_values_tests.py diff --git a/net/xeaf/rack/management/commands/rack_coverage.py b/net/xeaf/rack/management/commands/rack_coverage.py index ff1599c..438025e 100644 --- a/net/xeaf/rack/management/commands/rack_coverage.py +++ b/net/xeaf/rack/management/commands/rack_coverage.py @@ -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]]: diff --git a/net/xeaf/rack/management/commands/rack_coverage_visitor.py b/net/xeaf/rack/management/commands/rack_coverage_visitor.py deleted file mode 100644 index 5f2cb67..0000000 --- a/net/xeaf/rack/management/commands/rack_coverage_visitor.py +++ /dev/null @@ -1,75 +0,0 @@ -# DRF Rack -# Библиотека классов расширений для Django REST Framework -# -# Автор: Николай В. Анохин -# Все права защищены. Лицензия: 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)) - diff --git a/net/xeaf/rack/tests/core/core_enum_values_tests.py b/net/xeaf/rack/tests/core/core_enum_values_tests.py new file mode 100644 index 0000000..e69de29