# [MODULE] Superset API Client # @contract: Реализует полное взаимодействие с Superset API # @semantic_layers: # 1. Авторизация/CSRF (делегируется `APIClient`) # 2. Основные операции (получение метаданных, список дашбордов) # 3. Импорт/экспорт дашбордов # @coherence: # - Согласован с `models.SupersetConfig` для конфигурации. # - Полная обработка всех ошибок из `exceptions.py` (делегируется `APIClient` и дополняется специфичными). # - Полностью использует `utils.network.APIClient` для всех HTTP-запросов. # [IMPORTS] Стандартная библиотека import json from typing import Optional, Dict, Tuple, List, Any, Literal, Union import datetime from pathlib import Path from requests import Response import zipfile # Для валидации ZIP-файлов # [IMPORTS] Сторонние библиотеки (убраны requests и urllib3, т.к. они теперь в network.py) # [IMPORTS] Локальные модули from superset_tool.models import SupersetConfig from superset_tool.exceptions import ( AuthenticationError, SupersetAPIError, DashboardNotFoundError, NetworkError, PermissionDeniedError, ExportError, InvalidZipFormatError ) from superset_tool.utils.fileio import get_filename_from_headers from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.network import APIClient # [REFACTORING_TARGET] Использование APIClient # [CONSTANTS] Общие константы (для информации, т.к. тайм-аут теперь в конфиге) DEFAULT_TIMEOUT = 30 # seconds - используется как значение по умолчанию в SupersetConfig # [TYPE-ALIASES] Для сложных сигнатур JsonType = Union[Dict[str, Any], List[Dict[str, Any]]] ResponseType = Tuple[bytes, str] # [CHECK] Валидация импортов для контрактов # [COHERENCE_CHECK_PASSED] Теперь зависимость на requests и urllib3 скрыта за APIClient try: from .utils.fileio import get_filename_from_headers as fileio_check assert callable(fileio_check) from .utils.network import APIClient as network_check assert callable(network_check) except (ImportError, AssertionError) as imp_err: raise RuntimeError( f"[COHERENCE_CHECK_FAILED] Импорт не прошел валидацию: {str(imp_err)}" ) from imp_err class SupersetClient: """[MAIN-CONTRACT] Клиент для работы с Superset API @pre: - `config` должен быть валидным `SupersetConfig`. - Целевой API доступен и учетные данные корректны. @post: - Все методы возвращают ожидаемые данные или вызывают явные, типизированные ошибки. - Токены для API-вызовов автоматически управляются (`APIClient`). @invariant: - Сессия остается валидной между вызовами. - Все ошибки типизированы согласно `exceptions.py`. - Все HTTP-запросы проходят через `self.network`. """ def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None): """[INIT] Инициализация клиента Superset. @semantic: - Валидирует входную конфигурацию. - Инициализирует внутренний `APIClient` для сетевого взаимодействия. - Выполняет первичную аутентификацию через `APIClient`. """ # [PRECONDITION] Валидация конфигурации self.logger = logger or SupersetLogger(name="SupersetClient") self._validate_config(config) self.config = config # [ANCHOR] API_CLIENT_INIT # [REFACTORING_COMPLETE] Теперь вся сетевая логика инкапсулирована в APIClient. # APIClient отвечает за аутентификацию, повторные попытки и обработку низкоуровневых ошибок. self.network = APIClient( base_url=config.base_url, auth=config.auth, verify_ssl=config.verify_ssl, timeout=config.timeout, logger=self.logger # Передаем логгер в APIClient ) try: # Аутентификация выполняется в конструкторе APIClient или по первому запросу # Для явного вызова: self.network.authenticate() # APIClient сам управляет токенами после первого успешного входа self.logger.info( "[COHERENCE_CHECK_PASSED] Клиент Superset успешно инициализирован", extra={"base_url": config.base_url} ) except Exception as e: self.logger.error( "[INIT_FAILED] Ошибка инициализации клиента Superset", exc_info=True, extra={"config_base_url": config.base_url, "error": str(e)} ) raise # Перевыброс ошибки инициализации def _validate_config(self, config: SupersetConfig) -> None: """[PRECONDITION] Валидация конфигурации клиента. @semantic: - Проверяет, что `config` является экземпляром `SupersetConfig`. - Проверяет обязательные поля `base_url` и `auth`. - Логирует ошибки валидации. @raise: - `TypeError`: если `config` не является `SupersetConfig`. - `ValueError`: если отсутствуют обязательные поля или они невалидны. """ if not isinstance(config, SupersetConfig): self.logger.error( "[CONTRACT_VIOLATION] Некорректный тип конфигурации", extra={"actual_type": type(config).__name__} ) raise TypeError("Конфигурация должна быть экземпляром SupersetConfig") # Pydantic SupersetConfig уже выполняет основную валидацию через Field и validator. # Здесь можно добавить дополнительные бизнес-правила или проверки доступности, если нужно. try: # Попытка доступа к полям через Pydantic для проверки их существования _ = config.base_url _ = config.auth _ = config.auth.get("username") _ = config.auth.get("password") self.logger.debug("[COHERENCE_CHECK_PASSED] Конфигурация SupersetClient прошла внутреннюю валидацию.") except Exception as e: self.logger.error( f"[CONTRACT_VIOLATION] Ошибка валидации полей конфигурации: {e}", extra={"config_dict": config.dict()} ) raise ValueError(f"Конфигурация SupersetConfig невалидна: {e}") from e @property def headers(self) -> dict: """[INTERFACE] Базовые заголовки для API-вызовов. @semantic: Делегирует получение актуальных заголовков `APIClient`. @post: Всегда возвращает актуальные токены и CSRF-токен. @invariant: Заголовки содержат 'Authorization' и 'X-CSRFToken'. """ # [REFACTORING_COMPLETE] Заголовки теперь управляются APIClient. return self.network.headers # [SECTION] API для получения списка дашбордов или получения одного дашборда def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: """[CONTRACT] Получение списка дашбордов с пагинацией. @pre: - Клиент должен быть авторизован. - Параметры `query` (если предоставлены) должны быть валидны для API Superset. @post: - Возвращает кортеж: (общее_количество_дашбордов, список_метаданных_дашбордов). - Обходит пагинацию для получения всех доступных дашбордов. @invariant: - Всегда возвращает полный список (если `total_count` > 0). @raise: - `SupersetAPIError`: При ошибках API (например, неверный формат ответа). - `NetworkError`: При проблемах с сетью. - `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка). """ self.logger.info("[INFO] Запрос списка всех дашбордов.") # [COHERENCE_CHECK] Валидация и нормализация параметров запроса validated_query = self._validate_query_params(query) self.logger.debug("[DEBUG] Параметры запроса списка дашбордов после валидации.", extra={"validated_query": validated_query}) try: # [ANCHOR] FETCH_TOTAL_COUNT total_count = self._fetch_total_object_count(endpoint="/dashboard/") self.logger.info(f"[INFO] Обнаружено {total_count} дашбордов в системе.") # [ANCHOR] FETCH_ALL_PAGES paginated_data = self._fetch_all_pages(endpoint="/dashboard/", query=validated_query, total_count=total_count) self.logger.info( f"[COHERENCE_CHECK_PASSED] Успешно получено {len(paginated_data)} дашбордов из {total_count}." ) return total_count, paginated_data except (SupersetAPIError, NetworkError, ValueError, PermissionDeniedError) as e: self.logger.error(f"[ERROR] Ошибка при получении списка дашбордов: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise except Exception as e: error_ctx = {"query": query, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении списка дашбордов: {str(e)}", exc_info=True, extra=error_ctx) raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context=error_ctx) from e def get_dashboard(self, dashboard_id_or_slug: str) -> dict: """[CONTRACT] Получение метаданных дашборда по ID или SLUG. @pre: - `dashboard_id_or_slug` должен быть строкой (ID или slug). - Клиент должен быть аутентифицирован (токены актуальны). @post: - Возвращает `dict` с метаданными дашборда. @raise: - `DashboardNotFoundError`: Если дашборд не найден (HTTP 404). - `SupersetAPIError`: При других ошибках API. - `NetworkError`: При проблемах с сетью. """ self.logger.info(f"[INFO] Запрос метаданных дашборда: {dashboard_id_or_slug}") try: response_data = self.network.request( method="GET", endpoint=f"/dashboard/{dashboard_id_or_slug}", # headers=self.headers # [REFACTORING_NOTE] APIClient теперь сам добавляет заголовки ) # [POSTCONDITION] Проверка структуры ответа if "result" not in response_data: self.logger.warning("[CONTRACT_VIOLATION] Ответ API не содержит поле 'result'", extra={"response": response_data}) raise SupersetAPIError("Некорректный формат ответа API при получении дашборда") self.logger.debug(f"[DEBUG] Метаданные дашборда '{dashboard_id_or_slug}' успешно получены.") return response_data["result"] except (DashboardNotFoundError, SupersetAPIError, NetworkError, PermissionDeniedError) as e: self.logger.error(f"[ERROR] Не удалось получить дашборд '{dashboard_id_or_slug}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise # Перевыброс уже типизированной ошибки except Exception as e: self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении дашборда '{dashboard_id_or_slug}': {str(e)}", exc_info=True) raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context={"dashboard_id_or_slug": dashboard_id_or_slug}) from e # [SECTION] API для получения списка датасетов или получения одного датасета def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: """[CONTRACT] Получение списка датасетов с пагинацией. @pre: - Клиент должен быть авторизован. - Параметры `query` (если предоставлены) должны быть валидны для API Superset. @post: - Возвращает кортеж: (общее_количество_датасетов, список_метаданных_датасетов). - Обходит пагинацию для получения всех доступных датасетов. @invariant: - Всегда возвращает полный список (если `total_count` > 0). @raise: - `SupersetAPIError`: При ошибках API (например, неверный формат ответа). - `NetworkError`: При проблемах с сетью. - `ValueError`: При некорректных параметрах пагинации (внутренняя ошибка). """ self.logger.info("[INFO] Запрос списка всех датасетов") try: # Получаем общее количество датасетов total_count = self._fetch_total_object_count(endpoint="/dataset/") self.logger.info(f"[INFO] Обнаружено {total_count} датасетов в системе") # Валидируем параметры запроса base_query = { "columns": ["id", "table_name", "sql", "database", "schema"], "page": 0, "page_size": 100 } validated_query = {**base_query, **(query or {})} # Получаем все страницы datasets = self._fetch_all_pages( endpoint="/dataset/", query=validated_query, total_count=total_count#, #results_field="result" ) self.logger.info( f"[COHERENCE_CHECK_PASSED] Успешно получено {len(datasets)} датасетов" ) return total_count, datasets except Exception as e: error_ctx = {"query": query, "error_type": type(e).__name__} self.logger.error( f"[ERROR] Ошибка получения списка датасетов: {str(e)}", exc_info=True, extra=error_ctx ) raise def get_dataset(self, dataset_id: str) -> dict: """[CONTRACT] Получение метаданных датасета по ID. @pre: - `dataset_id` должен быть строкой (ID или slug). - Клиент должен быть аутентифицирован (токены актуальны). @post: - Возвращает `dict` с метаданными датасета. @raise: - `DashboardNotFoundError`: Если дашборд не найден (HTTP 404). - `SupersetAPIError`: При других ошибках API. - `NetworkError`: При проблемах с сетью. """ self.logger.info(f"[INFO] Запрос метаданных дашборда: {dataset_id}") try: response_data = self.network.request( method="GET", endpoint=f"/dataset/{dataset_id}", # headers=self.headers # [REFACTORING_NOTE] APIClient теперь сам добавляет заголовки ) # [POSTCONDITION] Проверка структуры ответа if "result" not in response_data: self.logger.warning("[CONTRACT_VIOLATION] Ответ API не содержит поле 'result'", extra={"response": response_data}) raise SupersetAPIError("Некорректный формат ответа API при получении дашборда") self.logger.debug(f"[DEBUG] Метаданные дашборда '{dataset_id}' успешно получены.") return response_data["result"] except (DashboardNotFoundError, SupersetAPIError, NetworkError, PermissionDeniedError) as e: self.logger.error(f"[ERROR] Не удалось получить дашборд '{dataset_id}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise # Перевыброс уже типизированной ошибки except Exception as e: self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении дашборда '{dataset_id}': {str(e)}", exc_info=True) raise SupersetAPIError(f"Непредвиденная ошибка: {str(e)}", context={"dashboard_id_or_slug": dataset_id}) from e # [SECTION] EXPORT OPERATIONS def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]: """[CONTRACT] Экспорт дашборда в ZIP-архив. @pre: - `dashboard_id` должен быть целочисленным ID существующего дашборда. - Пользователь должен иметь права на экспорт. @post: - Возвращает кортеж: (бинарное_содержимое_zip, имя_файла). - Имя файла извлекается из заголовков `Content-Disposition` или генерируется. @raise: - `DashboardNotFoundError`: Если дашборд с `dashboard_id` не найден (HTTP 404). - `ExportError`: При любых других проблемах экспорта (например, неверный тип контента, пустой ответ). - `NetworkError`: При проблемах с сетью. """ self.logger.info(f"[INFO] Запуск экспорта дашборда с ID: {dashboard_id}") try: # [ANCHOR] EXECUTE_EXPORT_REQUEST # [REFACTORING_COMPLETE] Использование self.network.request для экспорта response = self.network.request( method="GET", endpoint="/dashboard/export/", params={"q": json.dumps([dashboard_id])}, stream=True, # Используем stream для обработки больших файлов raw_response=True # Получаем сырой объект ответа requests.Response # headers=self.headers # APIClient сам добавляет заголовки ) response.raise_for_status() # Проверка статуса ответа # [ANCHOR] VALIDATE_EXPORT_RESPONSE self._validate_export_response(response, dashboard_id) # [ANCHOR] RESOLVE_FILENAME filename = self._resolve_export_filename(response, dashboard_id) # [POSTCONDITION] Успешный экспорт content = response.content # Получаем все содержимое self.logger.info( f"[COHERENCE_CHECK_PASSED] Дашборд {dashboard_id} успешно экспортирован. Размер: {len(content)} байт, Имя файла: {filename}" ) return content, filename except (DashboardNotFoundError, ExportError, NetworkError, PermissionDeniedError, SupersetAPIError) as e: # Перехват и перевыброс уже типизированных ошибок от APIClient или предыдущих валидаций self.logger.error(f"[ERROR] Ошибка экспорта дашборда {dashboard_id}: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise except Exception as e: # Обработка любых непредвиденных ошибок error_ctx = {"dashboard_id": dashboard_id, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при экспорте дашборда {dashboard_id}: {str(e)}", exc_info=True, extra=error_ctx) raise ExportError(f"Непредвиденная ошибка при экспорте: {str(e)}", context=error_ctx) from e # [HELPER] Метод _execute_export_request был инлайнирован в export_dashboard # Это сделано, чтобы избежать лишней абстракции, так как он просто вызывает self.network.request. # Валидация HTTP-ответа и ошибок теперь происходит в self.network.request и последующей self.raise_for_status(). def _validate_export_response(self, response: Response, dashboard_id: int) -> None: """[HELPER] Валидация ответа экспорта. @semantic: - Проверяет, что Content-Type является `application/zip`. - Проверяет, что ответ не пуст. @raise: - `ExportError`: При невалидном Content-Type или пустом содержимом. """ content_type = response.headers.get('Content-Type', '') if 'application/zip' not in content_type: self.logger.error( "[CONTRACT_VIOLATION] Неверный Content-Type для экспорта", extra={ "dashboard_id": dashboard_id, "expected_type": "application/zip", "received_type": content_type } ) raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})") if not response.content: self.logger.error( "[CONTRACT_VIOLATION] Пустой ответ при экспорте дашборда", extra={"dashboard_id": dashboard_id} ) raise ExportError("Получены пустые данные при экспорте") self.logger.debug(f"[COHERENCE_CHECK_PASSED] Ответ экспорта для дашборда {dashboard_id} валиден.") def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str: """[HELPER] Определение имени экспортируемого файла. @semantic: - Пытается извлечь имя файла из заголовка `Content-Disposition`. - Если заголовок отсутствует, генерирует имя файла на основе ID дашборда и текущей даты. @post: - Возвращает строку с именем файла. """ filename = get_filename_from_headers(response.headers) if not filename: # [FALLBACK] Генерация имени файла filename = f"dashboard_export_{dashboard_id}_{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}.zip" self.logger.warning( "[WARN] Не удалось извлечь имя файла из заголовков. Используется сгенерированное имя.", extra={"generated_filename": filename, "dashboard_id": dashboard_id} ) else: self.logger.debug( "[DEBUG] Имя файла экспорта получено из заголовков.", extra={"filename": filename, "dashboard_id": dashboard_id} ) return filename def export_to_file(self, dashboard_id: int, output_dir: Union[str, Path]) -> Path: """[CONTRACT] Экспорт дашборда напрямую в файл. @pre: - `dashboard_id` должен быть существующим ID дашборда. - `output_dir` должен быть валидным, существующим путем и иметь права на запись. @post: - Дашборд экспортируется и сохраняется как ZIP-файл в `output_dir`. - Возвращает `Path` к сохраненному файлу. @raise: - `FileNotFoundError`: Если `output_dir` не существует. - `ExportError`: При ошибках экспорта или записи файла. - `NetworkError`: При проблемах с сетью. """ output_dir = Path(output_dir) if not output_dir.exists(): self.logger.error( "[CONTRACT_VIOLATION] Целевая директория для экспорта не найдена.", extra={"output_dir": str(output_dir)} ) raise FileNotFoundError(f"Директория {output_dir} не найдена") self.logger.info(f"[INFO] Экспорт дашборда {dashboard_id} в файл в директорию: {output_dir}") try: content, filename = self.export_dashboard(dashboard_id) target_path = output_dir / filename with open(target_path, 'wb') as f: f.write(content) self.logger.info( "[COHERENCE_CHECK_PASSED] Дашборд успешно сохранен на диск.", extra={ "dashboard_id": dashboard_id, "file_path": str(target_path), "file_size": len(content) } ) return target_path except (FileNotFoundError, ExportError, NetworkError, SupersetAPIError, DashboardNotFoundError) as e: self.logger.error(f"[ERROR] Ошибка сохранения дашборда {dashboard_id} на диск: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise except IOError as io_err: error_ctx = {"target_path": str(target_path), "dashboard_id": dashboard_id} self.logger.critical(f"[CRITICAL] Ошибка записи файла для дашборда {dashboard_id}: {str(io_err)}", exc_info=True, extra=error_ctx) raise ExportError("Ошибка сохранения файла на диск") from io_err except Exception as e: error_ctx = {"dashboard_id": dashboard_id, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при экспорте в файл: {str(e)}", exc_info=True, extra=error_ctx) raise ExportError(f"Непредвиденная ошибка экспорта в файл: {str(e)}", context=error_ctx) from e # [SECTION] Импорт дашбордов def import_dashboard(self, file_name: Union[str, Path]) -> Dict: """[CONTRACT] Импорт дашборда из ZIP-архива. @pre: - `file_name` должен указывать на существующий и валидный ZIP-файл Superset экспорта. - Пользователь должен иметь права на импорт дашбордов. @post: - Дашборд импортируется (или обновляется, если `overwrite` включен). - Возвращает `dict` с ответом API об импорте. @raise: - `FileNotFoundError`: Если файл не существует. - `InvalidZipFormatError`: Если файл не является корректным ZIP-архивом Superset. - `PermissionDeniedError`: Если у пользователя нет прав на импорт. - `SupersetAPIError`: При других ошибках API импорта. - `NetworkError`: При проблемах с сетью. """ self.logger.info(f"[INFO] Инициирован импорт дашборда из файла: {file_name}") # [PRECONDITION] Валидация входного файла self._validate_import_file(file_name) try: # [ANCHOR] UPLOAD_FILE_TO_API # [REFACTORING_COMPLETE] Использование self.network.upload_file import_response = self.network.upload_file( endpoint="/dashboard/import/", file_obj=Path(file_name), # Pathlib объект, который APIClient может преобразовать в бинарный file_name=Path(file_name).name, # Имя файла для FormData form_field="formData", extra_data={'overwrite': 'true'}, # Предполагаем, что всегда хотим перезаписывать timeout=self.config.timeout * 2 # Удвоенный таймаут для загрузки больших файлов # headers=self.headers # APIClient сам добавляет заголовки ) # [POSTCONDITION] Проверка успешного ответа импорта (Superset обычно возвращает JSON) if not isinstance(import_response, dict) or "message" not in import_response: self.logger.warning("[CONTRACT_VIOLATION] Неожиданный формат ответа при импорте", extra={"response": import_response}) raise SupersetAPIError("Неожиданный формат ответа после импорта дашборда.") self.logger.info( f"[COHERENCE_CHECK_PASSED] Дашборд из '{file_name}' успешно импортирован.", extra={"api_message": import_response.get("message", "N/A"), "file": file_name} ) return import_response except (FileNotFoundError, InvalidZipFormatError, PermissionDeniedError, SupersetAPIError, NetworkError, DashboardNotFoundError) as e: self.logger.error(f"[ERROR] Ошибка импорта дашборда из '{file_name}': {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise except Exception as e: error_ctx = {"file": file_name, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при импорте дашборда: {str(e)}", exc_info=True, extra=error_ctx) raise SupersetAPIError(f"Непредвиденная ошибка импорта: {str(e)}", context=error_ctx) from e # [SECTION] Приватные методы-помощники def _validate_query_params(self, query: Optional[Dict]) -> Dict: """[HELPER] Нормализация и валидация параметров запроса для списка дашбордов. @semantic: - Устанавливает значения по умолчанию для `columns`, `page`, `page_size`. - Объединяет предоставленные `query` параметры с дефолтными. @post: - Возвращает словарь с полными и валидными параметрами запроса. """ base_query = { "columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "page": 0, "page_size": 1000 # Достаточно большой размер страницы для обхода пагинации } # [COHERENCE_CHECK_PASSED] Параметры запроса сформированы корректно. return {**base_query, **(query or {})} def _fetch_total_object_count(self, endpoint:str) -> int: """[CONTRACT][HELPER] Получение общего количества объектов (дашбордов, датасетов, чартов, баз данных) в системе. @delegates: - Сетевой запрос к `APIClient.fetch_paginated_count`. @pre: - Клиент должен быть авторизован. @post: - Возвращает целочисленное количество дашбордов. @raise: - `SupersetAPIError` или `NetworkError` при проблемах с API/сетью. """ query_params_for_count = { 'columns': ['id'], 'page': 0, 'page_size': 1 } self.logger.debug("[DEBUG] Запрос общего количества дашбордов.") try: # [REFACTORING_COMPLETE] Использование self.network.fetch_paginated_count count = self.network.fetch_paginated_count( endpoint=endpoint, query_params=query_params_for_count, count_field="count" ) self.logger.debug(f"[COHERENCE_CHECK_PASSED] Получено общее количество дашбордов: {count}") return count except (SupersetAPIError, NetworkError, PermissionDeniedError) as e: self.logger.error(f"[ERROR] Ошибка получения общего количества дашбордов: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise # Перевыброс ошибки except Exception as e: error_ctx = {"error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при получении общего количества: {str(e)}", exc_info=True, extra=error_ctx) raise SupersetAPIError(f"Непредвиденная ошибка при получении count: {str(e)}", context=error_ctx) from e def _fetch_all_pages(self, endpoint:str, query: Dict, total_count: int) -> List[Dict]: """[CONTRACT][HELPER] Обход всех страниц пагинированного API для получения всех данных. @delegates: - Сетевые запросы к `APIClient.fetch_paginated_data()`. @pre: - `query` должен содержать `page_size`. - `total_count` должен быть корректным общим количеством элементов. - `endpoint` должен содержать часть url запроса, например endpoint="/dashboard/". @post: - Возвращает список всех элементов, собранных со всех страниц. @raise: - `SupersetAPIError` или `NetworkError` при проблемах с API/сетью. - `ValueError` при некорректных параметрах пагинации. """ self.logger.debug(f"[DEBUG] Запуск обхода пагинации. Всего элементов: {total_count}, query: {query}") try: if 'page_size' not in query or not query['page_size']: self.logger.error("[CONTRACT_VIOLATION] Параметр 'page_size' отсутствует или неверен в query.") raise ValueError("Отсутствует 'page_size' в query параметрах для пагинации") # [REFACTORING_COMPLETE] Использование self.network.fetch_paginated_data all_data = self.network.fetch_paginated_data( endpoint=endpoint, base_query=query, total_count=total_count, results_field="result" ) self.logger.debug(f"[COHERENCE_CHECK_PASSED] Успешно получено {len(all_data)} элементов со всех страниц.") return all_data except (SupersetAPIError, NetworkError, ValueError, PermissionDeniedError) as e: self.logger.error(f"[ERROR] Ошибка при обходе пагинации: {str(e)}", exc_info=True, extra=getattr(e, 'context', {})) raise except Exception as e: error_ctx = {"query": query, "total_count": total_count, "error_type": type(e).__name__} self.logger.critical(f"[CRITICAL] Непредвиденная ошибка при обходе пагинации: {str(e)}", exc_info=True, extra=error_ctx) raise SupersetAPIError(f"Непредвиденная ошибка пагинации: {str(e)}", context=error_ctx) from e def _validate_import_file(self, zip_path: Union[str, Path]) -> None: """[HELPER] Проверка файла перед импортом. @semantic: - Проверяет существование файла. - Проверяет, что файл является валидным ZIP-архивом. - Проверяет, что ZIP-архив содержит `metadata.yaml` (ключевой для экспорта Superset). @raise: - `FileNotFoundError`: Если файл не существует. - `InvalidZipFormatError`: Если файл не ZIP или не содержит `metadata.yaml`. """ path = Path(zip_path) self.logger.debug(f"[DEBUG] Валидация файла для импорта: {path}") if not path.exists(): self.logger.error( "[CONTRACT_VIOLATION] Файл для импорта не найден.", extra={"file_path": str(path)} ) raise FileNotFoundError(f"Файл {zip_path} не существует") if not zipfile.is_zipfile(path): self.logger.error( "[CONTRACT_VIOLATION] Файл не является валидным ZIP-архивом.", extra={"file_path": str(path)} ) raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP-архивом") try: with zipfile.ZipFile(path, 'r') as zf: # [CONTRACT] Проверяем наличие metadata.yaml if not any(n.endswith('metadata.yaml') for n in zf.namelist()): self.logger.error( "[CONTRACT_VIOLATION] ZIP-архив не содержит 'metadata.yaml'.", extra={"file_path": str(path), "zip_contents": zf.namelist()[:5]} # Логируем первые 5 файлов для отладки ) raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml', не является корректным экспортом Superset.") self.logger.debug(f"[COHERENCE_CHECK_PASSED] Файл '{path}' успешно прошел валидацию для импорта.") except zipfile.BadZipFile as e: self.logger.error( f"[CONTRACT_VIOLATION] Ошибка чтения ZIP-файла: {str(e)}", exc_info=True, extra={"file_path": str(path)} ) raise InvalidZipFormatError(f"Файл {zip_path} поврежден или имеет некорректный формат ZIP.") from e except Exception as e: self.logger.critical( f"[CRITICAL] Непредвиденная ошибка при валидации ZIP-файла: {str(e)}", exc_info=True, extra={"file_path": str(path)} ) raise SupersetAPIError(f"Непредвиденная ошибка валидации ZIP: {str(e)}", context={"file_path": str(path)}) from e