# DRF Rack # Библиотека классов расширений для Django REST Framework # # Автор: Николай В. Анохин # Все права защищены. Лицензия: 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 net.xeaf.rack.utils.management import CoverageStructureVisitor from net.xeaf.rack.utils.management 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.skipped_packages_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 {}