# 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 {}