diff --git a/.rackcoveragerc b/.rackcoveragerc new file mode 100644 index 0000000..573aabe --- /dev/null +++ b/.rackcoveragerc @@ -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__ diff --git a/net/xeaf/rack/core/tests/__init__.py b/net/xeaf/rack/core/testing/__init__.py similarity index 100% rename from net/xeaf/rack/core/tests/__init__.py rename to net/xeaf/rack/core/testing/__init__.py diff --git a/net/xeaf/rack/core/tests/core_simple_test_case.py b/net/xeaf/rack/core/testing/core_simple_test_case.py similarity index 100% rename from net/xeaf/rack/core/tests/core_simple_test_case.py rename to net/xeaf/rack/core/testing/core_simple_test_case.py diff --git a/net/xeaf/rack/core/tests/core_test_case.py b/net/xeaf/rack/core/testing/core_test_case.py similarity index 100% rename from net/xeaf/rack/core/tests/core_test_case.py rename to net/xeaf/rack/core/testing/core_test_case.py diff --git a/net/xeaf/rack/core/tests/core_user_story_test_case.py b/net/xeaf/rack/core/testing/core_user_story_test_case.py similarity index 100% rename from net/xeaf/rack/core/tests/core_user_story_test_case.py rename to net/xeaf/rack/core/testing/core_user_story_test_case.py diff --git a/net/xeaf/rack/core/tests/request_test_case_mixin.py b/net/xeaf/rack/core/testing/request_test_case_mixin.py similarity index 100% rename from net/xeaf/rack/core/tests/request_test_case_mixin.py rename to net/xeaf/rack/core/testing/request_test_case_mixin.py diff --git a/net/xeaf/rack/management/commands/coverage_structure_visitor.py b/net/xeaf/rack/management/commands/coverage_structure_visitor.py new file mode 100644 index 0000000..2cd994f --- /dev/null +++ b/net/xeaf/rack/management/commands/coverage_structure_visitor.py @@ -0,0 +1,75 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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)) diff --git a/net/xeaf/rack/management/commands/coverage_test_file_visitor.py b/net/xeaf/rack/management/commands/coverage_test_file_visitor.py new file mode 100644 index 0000000..96d9804 --- /dev/null +++ b/net/xeaf/rack/management/commands/coverage_test_file_visitor.py @@ -0,0 +1,33 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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) diff --git a/net/xeaf/rack/management/commands/rack_coverage.py b/net/xeaf/rack/management/commands/rack_coverage.py new file mode 100644 index 0000000..13352a7 --- /dev/null +++ b/net/xeaf/rack/management/commands/rack_coverage.py @@ -0,0 +1,352 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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 {} diff --git a/net/xeaf/rack/management/commands/rack_coverage2.py b/net/xeaf/rack/management/commands/rack_coverage2.py new file mode 100644 index 0000000..10809d7 --- /dev/null +++ b/net/xeaf/rack/management/commands/rack_coverage2.py @@ -0,0 +1,80 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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 {} + diff --git a/net/xeaf/rack/management/commands/rack_coverage_visitor.py b/net/xeaf/rack/management/commands/rack_coverage_visitor.py new file mode 100644 index 0000000..5f2cb67 --- /dev/null +++ b/net/xeaf/rack/management/commands/rack_coverage_visitor.py @@ -0,0 +1,75 @@ +# 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/__init__.py b/net/xeaf/rack/tests/core/__init__.py new file mode 100644 index 0000000..2debc16 --- /dev/null +++ b/net/xeaf/rack/tests/core/__init__.py @@ -0,0 +1,12 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания тестов классов ядра +""" + +from .core_enum_choices_tests import CoreEnumChoicesTests +from .core_enum_keys_tests import CoreEnumKeysTests diff --git a/net/xeaf/rack/tests/core/core_enum_choices_tests.py b/net/xeaf/rack/tests/core/core_enum_choices_tests.py new file mode 100644 index 0000000..8e1f66a --- /dev/null +++ b/net/xeaf/rack/tests/core/core_enum_choices_tests.py @@ -0,0 +1,7 @@ +from net.xeaf.rack.core.testing import CoreSimpleTestCase + + +class CoreEnumChoicesTests(CoreSimpleTestCase): + + def test_enum_choices(self): + self.assertEqual(1, 2) diff --git a/net/xeaf/rack/tests/core/core_enum_keys_tests.py b/net/xeaf/rack/tests/core/core_enum_keys_tests.py new file mode 100644 index 0000000..0b0ffb3 --- /dev/null +++ b/net/xeaf/rack/tests/core/core_enum_keys_tests.py @@ -0,0 +1,7 @@ +from net.xeaf.rack.core.testing import CoreSimpleTestCase + + +class CoreEnumKeysTests(CoreSimpleTestCase): + + def test_enum_keys(self): + self.assertEqual(1, 2)